Initial commit: Zorro domain registration prototype
- HTML frontend (admin, dashboard, index, login) - Node.js backend in server/ with auth, db, workers, domains, pdns, zorro - Project docs (PRD, TRD, summary, DNS), BPMN diagram
This commit is contained in:
commit
93783ec006
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# secrets
|
||||||
|
.jwt-secret
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# databases
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# IDE / tooling
|
||||||
|
.claude/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# separately versioned project (lives at vibe/gitea-pages)
|
||||||
|
fin_vibe_landing_ju/
|
||||||
287
DNS.md
Normal file
287
DNS.md
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
# DNS — PowerDNS на Mac Studio
|
||||||
|
|
||||||
|
Локальный DNS-сервер: **PowerDNS Authoritative** (зоны + REST API) + **pdns-recursor** (входная точка на `:53`).
|
||||||
|
|
||||||
|
## Эндпоинты
|
||||||
|
|
||||||
|
| | Где | Для чего |
|
||||||
|
|---|---|---|
|
||||||
|
| REST API | `http://127.0.0.1:8081/api/v1/servers/localhost` | управление зонами/записями |
|
||||||
|
| Auth DNS | `127.0.0.1:5300` | для прямых `dig` к нашим зонам |
|
||||||
|
| Recursor DNS | `0.0.0.0:53` (вся LAN) | резолвер для клиентов |
|
||||||
|
|
||||||
|
> В примерах ниже используется `203.0.113.10` — это RFC 5737 «documentation» IP (заменяйте на свой реальный, публичный или LAN).
|
||||||
|
|
||||||
|
## Авторизация
|
||||||
|
|
||||||
|
API-ключ в `~/.pdns-api-key`. Удобный alias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PDNS_KEY=$(cat ~/.pdns-api-key)
|
||||||
|
export PDNS_API=http://127.0.0.1:8081/api/v1/servers/localhost
|
||||||
|
alias pdns='curl -sS -H "X-API-Key: $PDNS_KEY" -H "Content-Type: application/json"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Дальше все примеры ниже используют `$PDNS_KEY` и `$PDNS_API`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поиск / просмотр
|
||||||
|
|
||||||
|
### Все зоны
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns $PDNS_API/zones | jq '.[].name'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Содержимое зоны `kt.` (все записи)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns $PDNS_API/zones/kt. | jq '.rrsets[] | {name, type, records: [.records[].content]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Найти конкретную запись
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# поиск по имени по всем зонам
|
||||||
|
pdns "$PDNS_API/search-data?q=crm.kt&max=20&object_type=record" | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
### Глобальный поиск (поддерживает wildcard `*`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns "$PDNS_API/search-data?q=*.asel.kt&max=50&object_type=record" | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
### Живая проверка (то, что реально отвечает DNS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# через recursor (так же ходят клиенты)
|
||||||
|
dig asel.kt +short
|
||||||
|
host crm.kt
|
||||||
|
|
||||||
|
# напрямую к auth (минуя recursor — полезно для отладки)
|
||||||
|
dig @127.0.0.1 -p 5300 asel.kt +short
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Добавление / изменение записей
|
||||||
|
|
||||||
|
PowerDNS делает PATCH на зону, ключевое поле — `changetype: REPLACE` (создаёт или заменяет).
|
||||||
|
|
||||||
|
### A-запись
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "grafana.kt.",
|
||||||
|
"type": "A",
|
||||||
|
"ttl": 60,
|
||||||
|
"changetype": "REPLACE",
|
||||||
|
"records": [{"content": "203.0.113.10", "disabled": false}]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Точка в конце имени (`grafana.kt.`) **обязательна** — FQDN.
|
||||||
|
|
||||||
|
### Несколько записей за раз
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [
|
||||||
|
{"name": "app1.kt.", "type": "A", "ttl": 60, "changetype": "REPLACE",
|
||||||
|
"records": [{"content": "203.0.113.10", "disabled": false}]},
|
||||||
|
{"name": "app2.kt.", "type": "A", "ttl": 60, "changetype": "REPLACE",
|
||||||
|
"records": [{"content": "203.0.113.10", "disabled": false}]},
|
||||||
|
{"name": "db.kt.", "type": "A", "ttl": 60, "changetype": "REPLACE",
|
||||||
|
"records": [{"content": "203.0.113.20", "disabled": false}]}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### CNAME (алиас)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "www.asel.kt.",
|
||||||
|
"type": "CNAME",
|
||||||
|
"ttl": 60,
|
||||||
|
"changetype": "REPLACE",
|
||||||
|
"records": [{"content": "asel.kt.", "disabled": false}]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wildcard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "*.dev.kt.",
|
||||||
|
"type": "A",
|
||||||
|
"ttl": 60,
|
||||||
|
"changetype": "REPLACE",
|
||||||
|
"records": [{"content": "203.0.113.10", "disabled": false}]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`*.dev.kt` → всё что угодно под `dev.kt` (`foo.dev.kt`, `bar.dev.kt`) резолвится в указанный IP.
|
||||||
|
|
||||||
|
### TXT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "_acme-challenge.asel.kt.",
|
||||||
|
"type": "TXT",
|
||||||
|
"ttl": 60,
|
||||||
|
"changetype": "REPLACE",
|
||||||
|
"records": [{"content": "\"your-token-here\"", "disabled": false}]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Кавычки внутри content **обязательны** для TXT (экранируются `\"`).
|
||||||
|
|
||||||
|
### MX
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "asel.kt.",
|
||||||
|
"type": "MX",
|
||||||
|
"ttl": 300,
|
||||||
|
"changetype": "REPLACE",
|
||||||
|
"records": [{"content": "10 mail.asel.kt.", "disabled": false}]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "grafana.kt.",
|
||||||
|
"type": "A",
|
||||||
|
"changetype": "DELETE"
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новые зоны
|
||||||
|
|
||||||
|
### Создать зону
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X POST "$PDNS_API/zones" -d '{
|
||||||
|
"name": "internal.kt.",
|
||||||
|
"kind": "Native",
|
||||||
|
"nameservers": ["ns1.kt."]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого в recursor'е запросы на `.internal.kt` автоматом попадут в auth (т.к. он авторитативен, а recursor форвардит `.kt` целиком).
|
||||||
|
|
||||||
|
### Удалить зону
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns -X DELETE "$PDNS_API/zones/internal.kt."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сброс кэша recursor'а
|
||||||
|
|
||||||
|
После добавления/изменения записи клиенты (и сам recursor) могут видеть старое значение из-за TTL. Сбросить:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# конкретное имя
|
||||||
|
sudo -A /opt/homebrew/opt/pdnsrec/bin/rec_control \
|
||||||
|
--socket-dir=/opt/homebrew/var/run/pdns-recursor wipe-cache asel.kt
|
||||||
|
|
||||||
|
# всю зону
|
||||||
|
sudo -A /opt/homebrew/opt/pdnsrec/bin/rec_control \
|
||||||
|
--socket-dir=/opt/homebrew/var/run/pdns-recursor wipe-cache kt$
|
||||||
|
```
|
||||||
|
|
||||||
|
Суффикс `$` в аргументе `wipe-cache` = "и всё под этой зоной".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрые функции для zsh
|
||||||
|
|
||||||
|
Можно закинуть в `~/.zshrc`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdns_add_a() {
|
||||||
|
# usage: pdns_add_a <name.kt> <ip>
|
||||||
|
local name="$1" ip="$2"
|
||||||
|
curl -sS -H "X-API-Key: $(cat ~/.pdns-api-key)" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X PATCH "http://127.0.0.1:8081/api/v1/servers/localhost/zones/kt." \
|
||||||
|
-d "{\"rrsets\":[{\"name\":\"${name}.\",\"type\":\"A\",\"ttl\":60,\"changetype\":\"REPLACE\",\"records\":[{\"content\":\"${ip}\",\"disabled\":false}]}]}" \
|
||||||
|
&& echo "added ${name} → ${ip}"
|
||||||
|
sudo -A /opt/homebrew/opt/pdnsrec/bin/rec_control \
|
||||||
|
--socket-dir=/opt/homebrew/var/run/pdns-recursor wipe-cache "$name" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
pdns_rm() {
|
||||||
|
# usage: pdns_rm <name.kt> [type=A]
|
||||||
|
local name="$1" type="${2:-A}"
|
||||||
|
curl -sS -H "X-API-Key: $(cat ~/.pdns-api-key)" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X PATCH "http://127.0.0.1:8081/api/v1/servers/localhost/zones/kt." \
|
||||||
|
-d "{\"rrsets\":[{\"name\":\"${name}.\",\"type\":\"${type}\",\"changetype\":\"DELETE\"}]}" \
|
||||||
|
&& echo "removed ${name} (${type})"
|
||||||
|
}
|
||||||
|
|
||||||
|
pdns_list() {
|
||||||
|
# usage: pdns_list [zone=kt]
|
||||||
|
local zone="${1:-kt}."
|
||||||
|
curl -sS -H "X-API-Key: $(cat ~/.pdns-api-key)" \
|
||||||
|
"http://127.0.0.1:8081/api/v1/servers/localhost/zones/${zone}" \
|
||||||
|
| jq -r '.rrsets[] | "\(.name)\t\(.type)\t\(.records[].content)"' \
|
||||||
|
| column -t
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
```
|
||||||
|
pdns_add_a grafana.kt 203.0.113.10 # IP — ваш публичный или внутренний
|
||||||
|
pdns_list
|
||||||
|
pdns_rm grafana.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Устройство (как это работает под капотом)
|
||||||
|
|
||||||
|
```
|
||||||
|
Клиент (Mac / LAN-ноут / docker-контейнер)
|
||||||
|
│
|
||||||
|
▼ запрос к 127.0.0.1:53 (Mac) или <LAN-IP>:53 (офисные ноуты) или 192.168.5.2:53 (контейнер через lima hostResolver → Mac system DNS)
|
||||||
|
pdns-recursor (:53)
|
||||||
|
├── `.kt` → форвард в 127.0.0.1:5300
|
||||||
|
└── всё остальное → рекурсивно через root hints / fallback 1.1.1.1
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
публичный DNS
|
||||||
|
pdns-auth (:5300) ← авторитативный
|
||||||
|
├── SQLite /opt/homebrew/var/powerdns/pdns.sqlite3
|
||||||
|
└── REST API (:8081) ← сюда мы пишем
|
||||||
|
```
|
||||||
|
|
||||||
|
- Конфиги: `/opt/homebrew/etc/powerdns/pdns.conf` (auth), `/opt/homebrew/etc/powerdns/recursor.yml` (recursor).
|
||||||
|
- Логи: `/opt/homebrew/var/log/pdnsrec.log`.
|
||||||
|
- Старт/стоп:
|
||||||
|
- `brew services start/stop pdns` (auth)
|
||||||
|
- `sudo -A launchctl bootstrap/bootout system /Library/LaunchDaemons/homebrew.mxcl.pdnsrec.plist` (recursor)
|
||||||
744
admin.html
Normal file
744
admin.html
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Панель администратора — domain.kt</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--blue: #005baa;
|
||||||
|
--blue-dark: #003f7a;
|
||||||
|
--blue-light: #e8f0fb;
|
||||||
|
--green: #1ab36e;
|
||||||
|
--green-bg: #e6f9f0;
|
||||||
|
--orange: #f07200;
|
||||||
|
--orange-bg: #fff3e6;
|
||||||
|
--red: #d93636;
|
||||||
|
--red-bg: #fdeaea;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
--purple-bg: #f3f0ff;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--bg: #f3f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
--header-h: 60px;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 2px 12px rgba(0,91,170,.08);
|
||||||
|
}
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; background: var(--bg); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── SIDEBAR ──────────────────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w);
|
||||||
|
background: #1a1a2e; display: flex; flex-direction: column; z-index: 100;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.08); text-decoration: none;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.sidebar-logo:hover { opacity: .8; }
|
||||||
|
.dot-kt { background: #60a5fa; color: #1a1a2e; font-size: .72rem; font-weight: 800; padding: 2px 7px; border-radius: 5px; }
|
||||||
|
.logo-text { color: #fff; font-weight: 700; font-size: .95rem; }
|
||||||
|
.logo-text span { display: block; font-size: .65rem; opacity: .45; font-weight: 400; color: #f87171; }
|
||||||
|
|
||||||
|
.sidebar-nav { flex: 1; padding: 10px 0; overflow-y: auto; }
|
||||||
|
.nav-label { padding: 8px 18px 3px; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: rgba(255,255,255,.3); }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 10px 18px;
|
||||||
|
color: rgba(255,255,255,.7); cursor: pointer; border: none; background: none;
|
||||||
|
width: 100%; text-align: left; font-size: .85rem; text-decoration: none;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||||
|
.nav-item.active { background: rgba(255,255,255,.1); color: #fff; border-left: 3px solid #f87171; }
|
||||||
|
.nav-item .badge {
|
||||||
|
margin-left: auto; padding: 1px 6px; border-radius: 20px;
|
||||||
|
font-size: .62rem; font-weight: 700; color: #fff;
|
||||||
|
}
|
||||||
|
.badge.red { background: var(--red); }
|
||||||
|
.badge.orange { background: var(--orange); }
|
||||||
|
.badge.green { background: var(--green); }
|
||||||
|
|
||||||
|
.sidebar-user { padding: 12px 18px; border-top: 1px solid rgba(255,255,255,.08); display: flex; align-items: center; gap: 10px; }
|
||||||
|
.avatar { width: 32px; height: 32px; border-radius: 50%; background: #f87171; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .78rem; flex-shrink: 0; }
|
||||||
|
.user-info { flex: 1; min-width: 0; }
|
||||||
|
.user-name { color: #fff; font-size: .82rem; font-weight: 600; }
|
||||||
|
.user-role { color: rgba(255,255,255,.4); font-size: .68rem; }
|
||||||
|
|
||||||
|
/* ── HEADER ───────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
position: fixed; top: 0; left: var(--sidebar-w); right: 0; height: var(--header-h);
|
||||||
|
background: var(--card); border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; padding: 0 28px; gap: 16px; z-index: 90;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
.header-title { font-size: 1rem; font-weight: 700; flex: 1; }
|
||||||
|
.header-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ── MAIN ─────────────────────────────────────────────────── */
|
||||||
|
.main { margin-left: var(--sidebar-w); margin-top: var(--header-h); padding: 24px; }
|
||||||
|
.page { display: none; animation: fadeIn .18s; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
@keyframes fadeIn { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:none; } }
|
||||||
|
|
||||||
|
/* ── KPI ──────────────────────────────────────────────────── */
|
||||||
|
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px,1fr)); gap: 14px; margin-bottom: 24px; }
|
||||||
|
.kpi { background: var(--card); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); display: flex; align-items: center; gap: 12px; }
|
||||||
|
.kpi-icon { width: 40px; height: 40px; border-radius: 9px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; }
|
||||||
|
.kpi-icon.blue { background: var(--blue-light); }
|
||||||
|
.kpi-icon.green { background: var(--green-bg); }
|
||||||
|
.kpi-icon.orange { background: var(--orange-bg); }
|
||||||
|
.kpi-icon.red { background: var(--red-bg); }
|
||||||
|
.kpi-icon.purple { background: var(--purple-bg); }
|
||||||
|
.kpi-val { font-size: 1.4rem; font-weight: 800; line-height: 1; }
|
||||||
|
.kpi-label { font-size: .72rem; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── FILTER BAR ───────────────────────────────────────────── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;
|
||||||
|
}
|
||||||
|
.filter-input {
|
||||||
|
flex: 1; min-width: 200px; padding: 9px 12px; border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: .86rem; outline: none; background: var(--card);
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.filter-input:focus { border-color: var(--blue); }
|
||||||
|
.filter-select {
|
||||||
|
padding: 9px 12px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||||
|
font-size: .86rem; background: var(--card); outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.filter-select:focus { border-color: var(--blue); }
|
||||||
|
|
||||||
|
/* ── TABLE ────────────────────────────────────────────────── */
|
||||||
|
.table-card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .83rem; }
|
||||||
|
thead th {
|
||||||
|
padding: 11px 14px; text-align: left; font-size: .7rem;
|
||||||
|
text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid var(--border); background: var(--bg); font-weight: 600;
|
||||||
|
}
|
||||||
|
tbody td { padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
tbody tr:hover td { background: var(--blue-light); }
|
||||||
|
tbody tr.conflict-row td { background: #fff8f0; }
|
||||||
|
tbody tr.conflict-row:hover td { background: var(--orange-bg); }
|
||||||
|
|
||||||
|
.domain-cell { font-weight: 700; font-family: monospace; font-size: .82rem; }
|
||||||
|
.domain-cell .kt { color: var(--blue); font-weight: 400; }
|
||||||
|
|
||||||
|
.status-pill { padding: 3px 9px; border-radius: 20px; font-size: .69rem; font-weight: 700; white-space: nowrap; }
|
||||||
|
.status-pill.active { background: var(--green-bg); color: var(--green); }
|
||||||
|
.status-pill.pending { background: var(--orange-bg); color: var(--orange); }
|
||||||
|
.status-pill.conflict { background: var(--red-bg); color: var(--red); }
|
||||||
|
.status-pill.rejected { background: #f1f1f1; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.action-btns { display: flex; gap: 6px; flex-wrap: nowrap; }
|
||||||
|
.btn-act {
|
||||||
|
padding: 5px 10px; border-radius: 6px; font-size: .75rem; font-weight: 600;
|
||||||
|
cursor: pointer; border: 1px solid var(--border); background: var(--bg);
|
||||||
|
color: var(--text); transition: background .12s, border-color .12s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-act:hover { background: var(--blue-light); border-color: var(--blue); color: var(--blue); }
|
||||||
|
.btn-act.approve:hover { background: var(--green-bg); border-color: var(--green); color: var(--green); }
|
||||||
|
.btn-act.reject:hover { background: var(--red-bg); border-color: var(--red); color: var(--red); }
|
||||||
|
.btn-act.reassign:hover { background: var(--orange-bg); border-color: var(--orange); color: var(--orange); }
|
||||||
|
|
||||||
|
/* ── CONFLICT BADGE ───────────────────────────────────────── */
|
||||||
|
.conflict-flag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--orange-bg); color: var(--orange);
|
||||||
|
font-size: .7rem; font-weight: 700; padding: 2px 8px; border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MODAL ────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; z-index: 200; }
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal { background: var(--card); border-radius: 16px; width: 480px; max-width: 94vw; padding: 28px; box-shadow: 0 12px 48px rgba(0,0,0,.2); animation: popIn .18s; }
|
||||||
|
@keyframes popIn { from { opacity:0; transform:scale(.94); } to { opacity:1; transform:none; } }
|
||||||
|
.modal-title { font-size: 1rem; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.modal-sub { font-size: .82rem; color: var(--text-muted); margin-bottom: 20px; }
|
||||||
|
.modal-info { background: var(--bg); border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: .82rem; line-height: 1.6; }
|
||||||
|
.modal-info strong { display: block; margin-bottom: 2px; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label { display: block; font-size: .74rem; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; text-transform: uppercase; }
|
||||||
|
.field input, .field select, .field textarea { width: 100%; padding: 9px 12px; border: 1.5px solid var(--border); border-radius: 8px; font-size: .86rem; background: var(--bg); color: var(--text); outline: none; transition: border-color .15s; }
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--blue); background: #fff; }
|
||||||
|
.btn-cancel { flex:1; padding:9px; border-radius:8px; font-size:.84rem; font-weight:600; border:1px solid var(--border); background:var(--bg); color:var(--text); cursor:pointer; }
|
||||||
|
.btn-confirm { flex:1; padding:9px; border-radius:8px; font-size:.84rem; font-weight:700; background:var(--blue); color:#fff; border:none; cursor:pointer; }
|
||||||
|
.btn-confirm.red { background:var(--red); }
|
||||||
|
.btn-confirm.orange { background:var(--orange); }
|
||||||
|
.btn-cancel:hover { background:var(--border); }
|
||||||
|
.btn-confirm:hover { filter:brightness(1.1); }
|
||||||
|
|
||||||
|
/* ── TOAST ────────────────────────────────────────────────── */
|
||||||
|
.toast { position:fixed; bottom:24px; right:24px; background:var(--text); color:#fff; padding:11px 18px; border-radius:9px; font-size:.84rem; font-weight:500; transform:translateY(60px); opacity:0; transition:transform .25s,opacity .25s; z-index:300; }
|
||||||
|
.toast.show { transform:translateY(0); opacity:1; }
|
||||||
|
.toast.green { background:var(--green); }
|
||||||
|
.toast.red { background:var(--red); }
|
||||||
|
.toast.orange { background:var(--orange); }
|
||||||
|
|
||||||
|
/* ── SECTION TITLE ────────────────────────────────────────── */
|
||||||
|
.sec-title { font-size:.71rem; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); font-weight:600; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
||||||
|
.sec-title::after { content:''; flex:1; height:1px; background:var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ──────────────────────────────────────────────────── -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a href="index.html" class="sidebar-logo">
|
||||||
|
<span class="dot-kt">.kt</span>
|
||||||
|
<div class="logo-text">domain.kt <span>⚙ Панель администратора</span></div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Управление</div>
|
||||||
|
<a href="#" class="nav-item active" data-page="all-domains">
|
||||||
|
🌐 Все домены <span class="badge green">1 240</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="pending">
|
||||||
|
⏳ На проверке <span class="badge orange">14</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="conflicts">
|
||||||
|
⚠️ Конфликты <span class="badge red">3</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="employees">
|
||||||
|
👥 Сотрудники
|
||||||
|
</a>
|
||||||
|
<div class="nav-label">Система</div>
|
||||||
|
<a href="#" class="nav-item" data-page="audit">
|
||||||
|
📋 Журнал действий
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="settings">
|
||||||
|
⚙️ Настройки системы
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="avatar">ИА</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">Иван Ахметов</div>
|
||||||
|
<div class="user-role">IT-администратор</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── HEADER ────────────────────────────────────────────────────── -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-title" id="header-title">Все домены</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="dashboard.html" style="padding:7px 14px;border-radius:8px;border:1px solid var(--border);background:var(--bg);font-size:.82rem;color:var(--text);text-decoration:none;font-weight:600;">
|
||||||
|
→ Кабинет сотрудника
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── MAIN ──────────────────────────────────────────────────────── -->
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- ════ PAGE: all-domains ════ -->
|
||||||
|
<div class="page active" id="page-all-domains">
|
||||||
|
|
||||||
|
<div class="kpi-row">
|
||||||
|
<div class="kpi"><div class="kpi-icon blue">🌐</div><div><div class="kpi-val">1 240</div><div class="kpi-label">Доменов всего</div></div></div>
|
||||||
|
<div class="kpi"><div class="kpi-icon green">✅</div><div><div class="kpi-val">1 218</div><div class="kpi-label">Активных</div></div></div>
|
||||||
|
<div class="kpi"><div class="kpi-icon orange">⏳</div><div><div class="kpi-val">14</div><div class="kpi-label">На проверке</div></div></div>
|
||||||
|
<div class="kpi"><div class="kpi-icon red">⚠️</div><div><div class="kpi-val">3</div><div class="kpi-label">Конфликтов</div></div></div>
|
||||||
|
<div class="kpi"><div class="kpi-icon purple">👥</div><div><div class="kpi-val">3 812</div><div class="kpi-label">Сотрудников</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec-title">🌐 Реестр доменов .kt</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input class="filter-input" id="filter-search" type="text" placeholder="🔍 Поиск по домену, CDN или имени..." oninput="filterTable()" />
|
||||||
|
<select class="filter-select" id="filter-status" onchange="filterTable()">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="active">Активные</option>
|
||||||
|
<option value="pending">На проверке</option>
|
||||||
|
<option value="conflict">Конфликты</option>
|
||||||
|
<option value="rejected">Отклонённые</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="filter-type" onchange="filterTable()">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="personal">Персональные</option>
|
||||||
|
<option value="project">Проектные</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<table id="domains-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Домен</th>
|
||||||
|
<th>CDN владельца</th>
|
||||||
|
<th>Сотрудник</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="domains-tbody">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-all-domains -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: pending ════ -->
|
||||||
|
<div class="page" id="page-pending">
|
||||||
|
<div class="sec-title">⏳ Заявки на проверке</div>
|
||||||
|
<div class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Домен</th>
|
||||||
|
<th>CDN</th>
|
||||||
|
<th>Сотрудник</th>
|
||||||
|
<th>Назначение</th>
|
||||||
|
<th>Подано</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pending-tbody"><!-- populated by JS --></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-pending -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: conflicts ════ -->
|
||||||
|
<div class="page" id="page-conflicts">
|
||||||
|
<div class="sec-title">⚠️ Конфликты — однофамильцы и спорные имена</div>
|
||||||
|
|
||||||
|
<!-- Conflict 1: same surname -->
|
||||||
|
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:18px;overflow:hidden;">
|
||||||
|
<div style="background:var(--orange-bg);border-bottom:1px solid var(--border);padding:14px 18px;display:flex;align-items:center;gap:10px;">
|
||||||
|
<span style="font-size:1.1rem;">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:.9rem;">Конфликт: однофамильцы</strong>
|
||||||
|
<span style="font-size:.78rem;color:var(--text-muted);margin-left:8px;">Два сотрудника претендуют на <code>tuleev.kt</code></span>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill conflict" style="margin-left:auto;">Требует решения</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 18px;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:.83rem;margin-bottom:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid var(--border);">
|
||||||
|
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">CDN</th>
|
||||||
|
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Сотрудник</th>
|
||||||
|
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Отдел</th>
|
||||||
|
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Дата заявки</th>
|
||||||
|
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Приоритет</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="background:var(--green-bg);">
|
||||||
|
<td style="padding:9px 10px;font-family:monospace;">tuleev.a</td>
|
||||||
|
<td style="padding:9px 10px;font-weight:600;">Аскар Тулеев</td>
|
||||||
|
<td style="padding:9px 10px;color:var(--text-muted);">Цифровые продукты</td>
|
||||||
|
<td style="padding:9px 10px;color:var(--text-muted);">10 апр 2026</td>
|
||||||
|
<td style="padding:9px 10px;"><span class="status-pill active">Первый</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:9px 10px;font-family:monospace;">tuleev.d</td>
|
||||||
|
<td style="padding:9px 10px;font-weight:600;">Данияр Тулеев</td>
|
||||||
|
<td style="padding:9px 10px;color:var(--text-muted);">Сетевые технологии</td>
|
||||||
|
<td style="padding:9px 10px;color:var(--text-muted);">11 апр 2026</td>
|
||||||
|
<td style="padding:9px 10px;"><span class="status-pill pending">Второй</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<button class="btn-act approve" onclick="resolveConflict('tuleev.a','tuleev.kt','tuleev.d','tuleev-d.kt')">
|
||||||
|
✓ Назначить tuleev.a → tuleev.kt, tuleev.d → tuleev-d.kt
|
||||||
|
</button>
|
||||||
|
<button class="btn-act reassign" onclick="openReassign('tuleev.kt')">
|
||||||
|
↩ Переназначить вручную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conflict 2: reserved name -->
|
||||||
|
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;">
|
||||||
|
<div style="background:var(--red-bg);border-bottom:1px solid var(--border);padding:14px 18px;display:flex;align-items:center;gap:10px;">
|
||||||
|
<span style="font-size:1.1rem;">🚫</span>
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:.9rem;">Зарезервированное имя</strong>
|
||||||
|
<span style="font-size:.78rem;color:var(--text-muted);margin-left:8px;">Сотрудник запросил <code>admin.kt</code> — системное имя</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill conflict" style="margin-left:auto;">Заблокировано</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 18px;">
|
||||||
|
<div style="font-size:.84rem;margin-bottom:14px;">
|
||||||
|
<strong>Заявитель:</strong> Берик Асанов (<code>asanov.b</code>) · Дата: 12 апр 2026<br>
|
||||||
|
<span style="color:var(--text-muted);">Имя <strong>admin</strong> зарезервировано для системных нужд и не может быть выдано сотрудникам.</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="btn-act reject" onclick="toastMsg('Заявка отклонена, сотрудник уведомлён','red')">
|
||||||
|
✗ Отклонить и уведомить
|
||||||
|
</button>
|
||||||
|
<button class="btn-act" onclick="toastMsg('Предложены альтернативы: asanov.kt, asanov-b.kt','orange')">
|
||||||
|
💡 Предложить альтернативы
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-conflicts -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: employees ════ -->
|
||||||
|
<div class="page" id="page-employees">
|
||||||
|
<div class="sec-title">👥 Сотрудники в системе</div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input class="filter-input" type="text" placeholder="🔍 Поиск по имени или CDN..." />
|
||||||
|
<select class="filter-select"><option>Все отделы</option><option>Цифровые продукты</option><option>Сетевые технологии</option><option>IT-безопасность</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>CDN</th>
|
||||||
|
<th>Сотрудник</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Отдел</th>
|
||||||
|
<th>Доменов</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>tuleev.a</code></td>
|
||||||
|
<td style="font-weight:600;">Аскар Тулеев</td>
|
||||||
|
<td style="color:var(--text-muted);">tuleev.a@telecom.kz</td>
|
||||||
|
<td style="color:var(--text-muted);">Цифровые продукты</td>
|
||||||
|
<td>3</td>
|
||||||
|
<td><span class="status-pill active">Активен</span></td>
|
||||||
|
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Аскар Тулеев...')">Профиль</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>bekova.m</code></td>
|
||||||
|
<td style="font-weight:600;">Мадина Бекова</td>
|
||||||
|
<td style="color:var(--text-muted);">bekova.m@telecom.kz</td>
|
||||||
|
<td style="color:var(--text-muted);">IT-безопасность</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td><span class="status-pill active">Активен</span></td>
|
||||||
|
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Мадина Бекова...')">Профиль</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>tuleev.d</code></td>
|
||||||
|
<td style="font-weight:600;">Данияр Тулеев</td>
|
||||||
|
<td style="color:var(--text-muted);">tuleev.d@telecom.kz</td>
|
||||||
|
<td style="color:var(--text-muted);">Сетевые технологии</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td><span class="status-pill conflict">Конфликт</span></td>
|
||||||
|
<td><button class="btn-act" onclick="showPage('conflicts')">→ Конфликт</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>asanov.b</code></td>
|
||||||
|
<td style="font-weight:600;">Берик Асанов</td>
|
||||||
|
<td style="color:var(--text-muted);">asanov.b@telecom.kz</td>
|
||||||
|
<td style="color:var(--text-muted);">Финансы</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td><span class="status-pill pending">На проверке</span></td>
|
||||||
|
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Берик Асанов...')">Профиль</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>dzhunusov.t</code></td>
|
||||||
|
<td style="font-weight:600;">Тимур Джунусов</td>
|
||||||
|
<td style="color:var(--text-muted);">dzhunusov.t@telecom.kz</td>
|
||||||
|
<td style="color:var(--text-muted);">Разработка</td>
|
||||||
|
<td>4</td>
|
||||||
|
<td><span class="status-pill active">Активен</span></td>
|
||||||
|
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Тимур Джунусов...')">Профиль</button></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-employees -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: audit ════ -->
|
||||||
|
<div class="page" id="page-audit">
|
||||||
|
<div class="sec-title">📋 Журнал действий администратора</div>
|
||||||
|
<div class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Администратор</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Объект</th>
|
||||||
|
<th>Результат</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td style="color:var(--text-muted);">14 апр 2026, 09:12</td><td>akhmetov.i</td><td>Одобрение домена</td><td><code>devops-team.kt</code></td><td><span class="status-pill active">Выполнено</span></td></tr>
|
||||||
|
<tr><td style="color:var(--text-muted);">13 апр 2026, 16:44</td><td>akhmetov.i</td><td>Отклонение домена</td><td><code>old-project.kt</code></td><td><span class="status-pill rejected">Отклонено</span></td></tr>
|
||||||
|
<tr><td style="color:var(--text-muted);">13 апр 2026, 11:05</td><td>akhmetov.i</td><td>Разрешение конфликта</td><td><code>bekova.kt</code></td><td><span class="status-pill active">Выполнено</span></td></tr>
|
||||||
|
<tr><td style="color:var(--text-muted);">12 апр 2026, 14:30</td><td>akhmetov.i</td><td>Блокировка имени</td><td><code>system.kt</code></td><td><span class="status-pill rejected">Заблокировано</span></td></tr>
|
||||||
|
<tr><td style="color:var(--text-muted);">11 апр 2026, 10:20</td><td>akhmetov.i</td><td>Переназначение домена</td><td><code>hr.kt</code></td><td><span class="status-pill active">Выполнено</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-audit -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: settings ════ -->
|
||||||
|
<div class="page" id="page-settings">
|
||||||
|
<div style="max-width:520px;">
|
||||||
|
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:16px;">
|
||||||
|
<div style="font-size:.9rem;font-weight:700;margin-bottom:16px;">Правила авто-регистрации</div>
|
||||||
|
<div class="field"><label>Авто-одобрение персональных доменов</label>
|
||||||
|
<select><option>Включено — мгновенно</option><option>Выключено — через администратора</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Срок рассмотрения проектных заявок (дни)</label>
|
||||||
|
<input type="number" value="2" min="1" max="14" />
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Максимум доменов на одного сотрудника</label>
|
||||||
|
<input type="number" value="10" min="1" />
|
||||||
|
</div>
|
||||||
|
<button style="padding:9px 20px;border-radius:8px;background:var(--blue);color:#fff;border:none;cursor:pointer;font-weight:700;font-size:.85rem;" onclick="toastMsg('Настройки сохранены','green')">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;">
|
||||||
|
<div style="font-size:.9rem;font-weight:700;margin-bottom:16px;">Зарезервированные имена</div>
|
||||||
|
<div style="font-size:.82rem;color:var(--text-muted);margin-bottom:10px;">Эти имена не могут быть зарегистрированы сотрудниками:</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;">
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">admin</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">mail</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">vpn</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">it</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">corp</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">support</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">intranet</span>
|
||||||
|
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">system</span>
|
||||||
|
</div>
|
||||||
|
<button style="padding:7px 14px;border-radius:8px;border:1px solid var(--border);background:var(--bg);font-size:.82rem;cursor:pointer;" onclick="toastMsg('Добавление нового зарезервированного имени...')">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-settings -->
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ── REJECT MODAL ───────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="modal-reject">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Отклонить заявку</div>
|
||||||
|
<div class="modal-sub">Домен: <strong id="reject-domain-name"></strong></div>
|
||||||
|
<div class="field"><label>Причина отклонения</label>
|
||||||
|
<textarea id="reject-reason" rows="3" placeholder="Укажите причину для сотрудника..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Предложить альтернативу (необязательно)</label>
|
||||||
|
<input type="text" id="reject-alt" placeholder="например: asanov-b.kt" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" onclick="closeModals()">Отмена</button>
|
||||||
|
<button class="btn-confirm red" onclick="confirmReject()">Отклонить и уведомить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── REASSIGN MODAL ─────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="modal-reassign">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Переназначить домен</div>
|
||||||
|
<div class="modal-sub">Домен: <strong id="reassign-domain-name"></strong></div>
|
||||||
|
<div class="field"><label>Назначить сотруднику (CDN)</label>
|
||||||
|
<input type="text" id="reassign-cdn" placeholder="tuleev.a" />
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Комментарий</label>
|
||||||
|
<textarea id="reassign-comment" rows="2" placeholder="Причина переназначения..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" onclick="closeModals()">Отмена</button>
|
||||||
|
<button class="btn-confirm orange" onclick="confirmReassign()">Переназначить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TOAST ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { api, requireAuth, logout, formatDate } from './app.js';
|
||||||
|
|
||||||
|
const user = requireAuth({ admin: true });
|
||||||
|
if (!user) throw new Error('redirecting');
|
||||||
|
|
||||||
|
const logoutBtn = document.querySelector('.logout-btn');
|
||||||
|
if (logoutBtn) logoutBtn.onclick = () => logout();
|
||||||
|
|
||||||
|
const STATUS_LABEL = { active:'Активен', pending:'На проверке', conflict:'Конфликт', inactive:'Отклонён', rejected:'Отклонён' };
|
||||||
|
const TYPE_LABEL = { personal:'Персональный', project:'Проектный' };
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allDomains = [];
|
||||||
|
let pendingTasks = [];
|
||||||
|
|
||||||
|
async function loadAllDomains() {
|
||||||
|
allDomains = await api('/admin/domains');
|
||||||
|
renderAllTable();
|
||||||
|
updateKpiAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPending() {
|
||||||
|
try {
|
||||||
|
pendingTasks = await api('/admin/domains?status=pending');
|
||||||
|
} catch (e) {
|
||||||
|
pendingTasks = [];
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
renderPendingTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKpiAll() {
|
||||||
|
const active = allDomains.filter(d => d.status === 'active').length;
|
||||||
|
const pending = pendingTasks.length;
|
||||||
|
const kpis = document.querySelectorAll('#page-all-domains .kpi-val');
|
||||||
|
if (kpis[0]) kpis[0].textContent = allDomains.length;
|
||||||
|
if (kpis[1]) kpis[1].textContent = active;
|
||||||
|
if (kpis[2]) kpis[2].textContent = pending;
|
||||||
|
if (kpis[3]) kpis[3].textContent = allDomains.filter(d => d.status === 'conflict').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllTable() {
|
||||||
|
const q = document.getElementById('filter-search')?.value.toLowerCase() || '';
|
||||||
|
const status = document.getElementById('filter-status')?.value || '';
|
||||||
|
const type = document.getElementById('filter-type')?.value || '';
|
||||||
|
const rows = allDomains.filter(d =>
|
||||||
|
(!q || d.name.includes(q) || d.owner_cdn?.includes(q) || d.owner_name?.toLowerCase().includes(q)) &&
|
||||||
|
(!status || d.status === status) &&
|
||||||
|
(!type || d.type === type)
|
||||||
|
);
|
||||||
|
document.getElementById('domains-tbody').innerHTML = rows.map(d => `
|
||||||
|
<tr class="${d.status==='conflict'?'conflict-row':''}">
|
||||||
|
<td class="domain-cell">${escapeHtml(d.name)}<span class="kt">.kt</span></td>
|
||||||
|
<td><code>${escapeHtml(d.owner_cdn || '')}</code></td>
|
||||||
|
<td>${escapeHtml(d.owner_name || '')}</td>
|
||||||
|
<td style="color:var(--text-muted);">${TYPE_LABEL[d.type] || d.type}</td>
|
||||||
|
<td><span class="status-pill ${d.status}">${STATUS_LABEL[d.status] || d.status}</span></td>
|
||||||
|
<td style="color:var(--text-muted);">${formatDate(d.created_at)}</td>
|
||||||
|
<td><div class="action-btns">
|
||||||
|
${d.status==='active' ? `<span style="color:var(--text-muted);font-size:.78rem;">A ${escapeHtml(d.dns_record || '')}</span>` : ''}
|
||||||
|
</div></td>
|
||||||
|
</tr>`).join('') || `<tr><td colspan="7" style="padding:20px;text-align:center;color:var(--text-muted);">Нет доменов</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPendingTable() {
|
||||||
|
const tbody = document.getElementById('pending-tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!pendingTasks.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" style="padding:20px;text-align:center;color:var(--text-muted);">Нет заявок на проверке</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = pendingTasks.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td class="domain-cell">${escapeHtml(t.name)}<span class="kt">.kt</span></td>
|
||||||
|
<td><code>${escapeHtml(t.owner_cdn || '')}</code></td>
|
||||||
|
<td>${escapeHtml(t.owner_email || '')}</td>
|
||||||
|
<td>${escapeHtml(t.purpose || '—')}</td>
|
||||||
|
<td style="color:var(--text-muted);">${formatDate(t.createdAt)}</td>
|
||||||
|
<td><div class="action-btns">
|
||||||
|
<button class="btn-act approve" data-task="${t.userTaskId}" data-name="${escapeHtml(t.fqdn)}">✓ Одобрить</button>
|
||||||
|
<button class="btn-act reject" data-task="${t.userTaskId}" data-name="${escapeHtml(t.fqdn)}">✗ Отклонить</button>
|
||||||
|
</div></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.btn-act.approve').forEach(btn => {
|
||||||
|
btn.onclick = () => approveTask(btn.dataset.task, btn.dataset.name);
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('.btn-act.reject').forEach(btn => {
|
||||||
|
btn.onclick = () => openReject(btn.dataset.task, btn.dataset.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveTask(taskId, domain) {
|
||||||
|
try {
|
||||||
|
await api(`/admin/user-tasks/${taskId}/approve`, { method: 'POST', body: { comment: '' } });
|
||||||
|
toastMsg(`✓ ${domain} одобрен, Zorro провизионит DNS`, 'green');
|
||||||
|
setTimeout(() => { loadPending(); loadAllDomains(); }, 3000);
|
||||||
|
setTimeout(() => { loadPending(); loadAllDomains(); }, 8000);
|
||||||
|
} catch (e) {
|
||||||
|
toastMsg(`Ошибка: ${e.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nav ─────────────────────────────────────────────────────────
|
||||||
|
const PAGE_TITLES = {
|
||||||
|
'all-domains':'Все домены','pending':'На проверке','conflicts':'Конфликты',
|
||||||
|
'employees':'Сотрудники','audit':'Журнал действий','settings':'Настройки системы'
|
||||||
|
};
|
||||||
|
function showPage(id) {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
const pg = document.getElementById('page-'+id);
|
||||||
|
if (pg) { pg.classList.add('active'); window.scrollTo({top:0,behavior:'smooth'}); }
|
||||||
|
const ni = document.querySelector(`.nav-item[data-page="${id}"]`);
|
||||||
|
if (ni) ni.classList.add('active');
|
||||||
|
document.getElementById('header-title').textContent = PAGE_TITLES[id] || id;
|
||||||
|
if (id === 'all-domains') loadAllDomains();
|
||||||
|
if (id === 'pending') loadPending();
|
||||||
|
}
|
||||||
|
window.showPage = showPage;
|
||||||
|
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||||
|
item.addEventListener('click', e => { e.preventDefault(); showPage(item.dataset.page); });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.filterTable = renderAllTable;
|
||||||
|
|
||||||
|
// ── Reject modal ────────────────────────────────────────────────
|
||||||
|
let rejectTarget = null;
|
||||||
|
function openReject(taskId, domainName) {
|
||||||
|
rejectTarget = taskId;
|
||||||
|
document.getElementById('reject-domain-name').textContent = domainName;
|
||||||
|
document.getElementById('reject-reason').value = '';
|
||||||
|
document.getElementById('reject-alt').value = '';
|
||||||
|
document.getElementById('modal-reject').classList.add('open');
|
||||||
|
}
|
||||||
|
window.openReject = openReject;
|
||||||
|
window.confirmReject = async function() {
|
||||||
|
const reason = document.getElementById('reject-reason').value.trim();
|
||||||
|
closeModals();
|
||||||
|
try {
|
||||||
|
await api(`/admin/user-tasks/${rejectTarget}/reject`, { method: 'POST', body: { comment: reason } });
|
||||||
|
toastMsg('Заявка отклонена. Сотрудник уведомлён.', 'red');
|
||||||
|
setTimeout(() => { loadPending(); loadAllDomains(); }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
toastMsg(`Ошибка: ${e.message}`, 'red');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.openReassign = (d) => toastMsg(`Переназначение пока не реализовано`, 'orange');
|
||||||
|
window.confirmReassign = () => closeModals();
|
||||||
|
window.resolveConflict = () => toastMsg('Конфликты пока не реализованы', 'orange');
|
||||||
|
|
||||||
|
function closeModals() {
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
|
||||||
|
}
|
||||||
|
window.closeModals = closeModals;
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(m => {
|
||||||
|
m.addEventListener('click', e => { if (e.target === m) closeModals(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────────
|
||||||
|
let toastTimer;
|
||||||
|
function toastMsg(msg, type='') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = 'toast show' + (type?' '+type:'');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(()=>{ t.className='toast'; }, 3500);
|
||||||
|
}
|
||||||
|
window.toastMsg = toastMsg;
|
||||||
|
|
||||||
|
// ── Initial load + auto-refresh pending every 5s ─────────────────
|
||||||
|
loadAllDomains();
|
||||||
|
loadPending();
|
||||||
|
setInterval(() => { if (document.getElementById('page-pending')?.classList.contains('active')) loadPending(); }, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
app.js
Normal file
60
app.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Shared API client for domain.kt frontend
|
||||||
|
const API_BASE = ''; // same origin (backend serves static)
|
||||||
|
const TOKEN_KEY = 'domain_kt_token';
|
||||||
|
const USER_KEY = 'domain_kt_user';
|
||||||
|
|
||||||
|
export function getToken() { return localStorage.getItem(TOKEN_KEY); }
|
||||||
|
export function getUser() { try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); } catch { return null; } }
|
||||||
|
|
||||||
|
export function saveSession(token, user) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api(path, options = {}) {
|
||||||
|
const headers = { ...(options.headers || {}) };
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
const res = await fetch(API_BASE + path, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body ? (typeof options.body === 'string' ? options.body : JSON.stringify(options.body)) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(data?.error || `HTTP ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.data = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(opts = {}) {
|
||||||
|
const u = getUser();
|
||||||
|
if (!u || !getToken()) { window.location.href = 'login.html'; return null; }
|
||||||
|
if (opts.admin && u.role !== 'admin') {
|
||||||
|
alert('Admin only');
|
||||||
|
window.location.href = 'dashboard.html';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso.replace(' ', 'T') + (iso.includes('Z') ? '' : 'Z'));
|
||||||
|
if (isNaN(d)) return iso;
|
||||||
|
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
748
dashboard.html
Normal file
748
dashboard.html
Normal file
@ -0,0 +1,748 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Личный кабинет — domain.kt</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--blue: #005baa;
|
||||||
|
--blue-dark: #003f7a;
|
||||||
|
--blue-light: #e8f0fb;
|
||||||
|
--green: #1ab36e;
|
||||||
|
--green-bg: #e6f9f0;
|
||||||
|
--orange: #f07200;
|
||||||
|
--orange-bg: #fff3e6;
|
||||||
|
--red: #d93636;
|
||||||
|
--red-bg: #fdeaea;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--bg: #f3f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--sidebar-w: 250px;
|
||||||
|
--header-h: 60px;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 2px 12px rgba(0,91,170,.08);
|
||||||
|
}
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; background: var(--bg); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── SIDEBAR ──────────────────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w);
|
||||||
|
background: var(--blue-dark); display: flex; flex-direction: column; z-index: 100;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 20px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.12);
|
||||||
|
text-decoration: none; cursor: pointer; transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.sidebar-logo:hover { opacity: .85; }
|
||||||
|
.dot-kt {
|
||||||
|
background: #60a5fa; color: var(--blue-dark);
|
||||||
|
font-size: .72rem; font-weight: 800; padding: 2px 7px; border-radius: 5px;
|
||||||
|
}
|
||||||
|
.logo-text { color: #fff; font-weight: 700; font-size: 1rem; }
|
||||||
|
.logo-text span { display: block; font-size: .68rem; opacity: .6; font-weight: 400; }
|
||||||
|
|
||||||
|
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
|
||||||
|
.nav-label {
|
||||||
|
padding: 8px 20px 4px; font-size: .63rem; text-transform: uppercase;
|
||||||
|
letter-spacing: .08em; color: rgba(255,255,255,.35);
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 20px; color: rgba(255,255,255,.75);
|
||||||
|
cursor: pointer; border: none; background: none;
|
||||||
|
width: 100%; text-align: left; font-size: .86rem;
|
||||||
|
text-decoration: none; transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: rgba(255,255,255,.07); color: #fff; }
|
||||||
|
.nav-item.active { background: rgba(255,255,255,.13); color: #fff; border-left: 3px solid #60a5fa; }
|
||||||
|
.nav-item .badge {
|
||||||
|
margin-left: auto; background: var(--orange); color: #fff;
|
||||||
|
font-size: .62rem; padding: 1px 6px; border-radius: 20px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
padding: 14px 20px; border-top: 1px solid rgba(255,255,255,.12);
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 34px; height: 34px; border-radius: 50%;
|
||||||
|
background: var(--blue); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700; font-size: .82rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.user-info { flex: 1; min-width: 0; }
|
||||||
|
.user-name { color: #fff; font-size: .83rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.user-cdn { color: rgba(255,255,255,.45); font-size: .7rem; font-family: monospace; }
|
||||||
|
.logout-btn {
|
||||||
|
color: rgba(255,255,255,.4); cursor: pointer; font-size: .8rem;
|
||||||
|
background: none; border: none; padding: 0; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.logout-btn:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* ── HEADER ───────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
position: fixed; top: 0; left: var(--sidebar-w); right: 0;
|
||||||
|
height: var(--header-h); background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; padding: 0 28px; gap: 16px; z-index: 90;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
.header-title { font-size: 1rem; font-weight: 700; flex: 1; }
|
||||||
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.btn-primary {
|
||||||
|
padding: 8px 18px; border-radius: 8px; background: var(--blue); color: #fff;
|
||||||
|
border: none; cursor: pointer; font-size: .84rem; font-weight: 700;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--blue-dark); }
|
||||||
|
|
||||||
|
/* ── MAIN ─────────────────────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar-w); margin-top: var(--header-h);
|
||||||
|
padding: 28px; min-height: calc(100vh - var(--header-h));
|
||||||
|
}
|
||||||
|
.page { display: none; animation: fadeIn .18s; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
@keyframes fadeIn { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:none; } }
|
||||||
|
|
||||||
|
/* ── SECTION TITLE ────────────────────────────────────────── */
|
||||||
|
.sec-title {
|
||||||
|
font-size: .72rem; text-transform: uppercase; letter-spacing: .07em;
|
||||||
|
color: var(--text-muted); font-weight: 600; margin-bottom: 14px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.sec-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
|
/* ── KPI ──────────────────────────────────────────────────── */
|
||||||
|
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap: 14px; margin-bottom: 28px; }
|
||||||
|
.kpi {
|
||||||
|
background: var(--card); border-radius: var(--radius); padding: 18px 20px;
|
||||||
|
box-shadow: var(--shadow); display: flex; align-items: center; gap: 14px;
|
||||||
|
}
|
||||||
|
.kpi-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; flex-shrink: 0; }
|
||||||
|
.kpi-icon.blue { background: var(--blue-light); }
|
||||||
|
.kpi-icon.green { background: var(--green-bg); }
|
||||||
|
.kpi-icon.orange { background: var(--orange-bg); }
|
||||||
|
.kpi-val { font-size: 1.5rem; font-weight: 800; line-height: 1; }
|
||||||
|
.kpi-label { font-size: .74rem; color: var(--text-muted); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ── DOMAIN CARDS ─────────────────────────────────────────── */
|
||||||
|
.domains-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px,1fr)); gap: 18px; margin-bottom: 28px; }
|
||||||
|
.domain-card {
|
||||||
|
background: var(--card); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); overflow: hidden; transition: box-shadow .2s;
|
||||||
|
}
|
||||||
|
.domain-card:hover { box-shadow: 0 4px 20px rgba(0,91,170,.13); }
|
||||||
|
.domain-card__head {
|
||||||
|
padding: 16px 18px 12px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
}
|
||||||
|
.domain-icon {
|
||||||
|
width: 42px; height: 42px; border-radius: 10px;
|
||||||
|
background: var(--blue-light); display: flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 1.1rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.domain-name { font-size: 1rem; font-weight: 700; line-height: 1.2; }
|
||||||
|
.domain-name .kt { color: var(--blue); }
|
||||||
|
.domain-type { font-size: .72rem; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.status-pill {
|
||||||
|
margin-left: auto; padding: 3px 10px; border-radius: 20px;
|
||||||
|
font-size: .7rem; font-weight: 700; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-pill.active { background: var(--green-bg); color: var(--green); }
|
||||||
|
.status-pill.pending { background: var(--orange-bg); color: var(--orange); }
|
||||||
|
.status-pill.conflict { background: var(--red-bg); color: var(--red); }
|
||||||
|
|
||||||
|
.domain-card__body { padding: 12px 18px; }
|
||||||
|
.info-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: .82rem; }
|
||||||
|
.info-row__label { color: var(--text-muted); }
|
||||||
|
.info-row__val { font-weight: 600; font-family: monospace; font-size: .8rem; }
|
||||||
|
|
||||||
|
.domain-card__actions { display: flex; gap: 8px; padding: 0 18px 16px; }
|
||||||
|
.btn-sm {
|
||||||
|
flex: 1; padding: 7px 10px; border-radius: 7px; font-size: .78rem;
|
||||||
|
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
|
||||||
|
background: var(--bg); color: var(--text); transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { background: var(--blue-light); border-color: var(--blue); color: var(--blue); }
|
||||||
|
.btn-sm.danger:hover { background: var(--red-bg); border-color: var(--red); color: var(--red); }
|
||||||
|
|
||||||
|
/* ── REGISTER FORM ────────────────────────────────────────── */
|
||||||
|
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 24px; }
|
||||||
|
.card-title { font-size: .95rem; font-weight: 700; margin-bottom: 18px; }
|
||||||
|
|
||||||
|
.reg-search-row {
|
||||||
|
display: flex; gap: 0; margin-bottom: 14px;
|
||||||
|
border: 1.5px solid var(--border); border-radius: 9px; overflow: hidden;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.reg-search-row:focus-within {
|
||||||
|
border-color: var(--blue); box-shadow: 0 0 0 3px rgba(0,91,170,.1);
|
||||||
|
}
|
||||||
|
.reg-suffix {
|
||||||
|
padding: 0 14px; background: var(--bg); color: var(--blue);
|
||||||
|
font-size: .92rem; font-weight: 800; display: flex; align-items: center;
|
||||||
|
border-left: 1px solid var(--border); white-space: nowrap; flex-shrink: 0;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
.reg-input {
|
||||||
|
flex: 1; padding: 11px 12px; border: none; outline: none;
|
||||||
|
font-size: .9rem; background: #fff; min-width: 0; color: var(--text);
|
||||||
|
}
|
||||||
|
.reg-check-btn {
|
||||||
|
padding: 0 20px; background: var(--blue); color: #fff;
|
||||||
|
border: none; cursor: pointer; font-size: .84rem; font-weight: 700;
|
||||||
|
transition: background .15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.reg-check-btn:hover { background: var(--blue-dark); }
|
||||||
|
|
||||||
|
.reg-result {
|
||||||
|
padding: 12px 14px; border-radius: 8px; font-size: .85rem;
|
||||||
|
margin-bottom: 14px; display: none;
|
||||||
|
}
|
||||||
|
.reg-result.free { background: var(--green-bg); color: var(--green); display: block; }
|
||||||
|
.reg-result.taken { background: var(--red-bg); color: var(--red); display: block; }
|
||||||
|
.reg-result.mine { background: var(--orange-bg); color: var(--orange); display: block; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label { display: block; font-size: .76rem; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: .88rem; color: var(--text);
|
||||||
|
background: var(--bg); outline: none; transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus {
|
||||||
|
border-color: var(--blue); box-shadow: 0 0 0 3px rgba(0,91,170,.1); background: #fff;
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; min-height: 70px; }
|
||||||
|
.field-hint { font-size: .75rem; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
|
||||||
|
/* ── CHANGE DOMAIN MODAL ──────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||||
|
display: none; align-items: center; justify-content: center; z-index: 200;
|
||||||
|
}
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--card); border-radius: 16px; width: 440px; max-width: 94vw;
|
||||||
|
padding: 28px; box-shadow: 0 12px 48px rgba(0,0,0,.2);
|
||||||
|
animation: popIn .2s;
|
||||||
|
}
|
||||||
|
@keyframes popIn { from { opacity:0; transform:scale(.94) translateY(8px); } to { opacity:1; transform:none; } }
|
||||||
|
.modal-title { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.modal-sub { font-size: .82rem; color: var(--text-muted); margin-bottom: 20px; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1; padding: 10px; border-radius: 8px; font-size: .85rem;
|
||||||
|
font-weight: 600; border: 1px solid var(--border); background: var(--bg);
|
||||||
|
color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { background: var(--border); }
|
||||||
|
.btn-confirm {
|
||||||
|
flex: 1; padding: 10px; border-radius: 8px; font-size: .85rem;
|
||||||
|
font-weight: 700; background: var(--blue); color: #fff; border: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-confirm:hover { background: var(--blue-dark); }
|
||||||
|
|
||||||
|
/* ── TOAST ────────────────────────────────────────────────── */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; right: 24px;
|
||||||
|
background: var(--text); color: #fff; padding: 11px 18px;
|
||||||
|
border-radius: 9px; font-size: .84rem; font-weight: 500;
|
||||||
|
transform: translateY(60px); opacity: 0; transition: transform .25s, opacity .25s; z-index: 300;
|
||||||
|
}
|
||||||
|
.toast.show { transform: translateY(0); opacity: 1; }
|
||||||
|
.toast.green { background: var(--green); }
|
||||||
|
.toast.red { background: var(--red); }
|
||||||
|
|
||||||
|
/* ── PROFILE ──────────────────────────────────────────────── */
|
||||||
|
.profile-grid { display: grid; grid-template-columns: 200px 1fr; gap: 20px; }
|
||||||
|
.profile-aside {
|
||||||
|
background: var(--card); border-radius: var(--radius); padding: 24px 16px;
|
||||||
|
box-shadow: var(--shadow); text-align: center; display: flex;
|
||||||
|
flex-direction: column; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.big-avatar {
|
||||||
|
width: 72px; height: 72px; border-radius: 50%;
|
||||||
|
background: var(--blue); color: #fff; font-size: 1.6rem; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) { .profile-grid { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ──────────────────────────────────────────────────── -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a href="index.html" class="sidebar-logo">
|
||||||
|
<span class="dot-kt">.kt</span>
|
||||||
|
<div class="logo-text">domain.kt<span>Личный кабинет</span></div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Домены</div>
|
||||||
|
<a href="#" class="nav-item active" data-page="my-domains">
|
||||||
|
🌐 Мои домены <span class="badge" id="badge-count">3</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="register-domain">
|
||||||
|
➕ Зарегистрировать домен
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="history">
|
||||||
|
🕐 История заявок
|
||||||
|
</a>
|
||||||
|
<div class="nav-label">Аккаунт</div>
|
||||||
|
<a href="#" class="nav-item" data-page="profile">
|
||||||
|
👤 Профиль
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="settings">
|
||||||
|
⚙️ Настройки
|
||||||
|
</a>
|
||||||
|
<a href="admin.html" class="nav-item" id="nav-admin-link" style="display:none;">
|
||||||
|
🛡️ Админ-панель
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="avatar" id="sidebar-avatar">АТ</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="sidebar-name">Аскар Тулеев</div>
|
||||||
|
<div class="user-cdn" id="sidebar-cdn">tuleev.a</div>
|
||||||
|
</div>
|
||||||
|
<button class="logout-btn" onclick="window.location='login.html'" title="Выйти">⏏</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── HEADER ────────────────────────────────────────────────────── -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-title" id="header-title">Мои домены</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-primary" onclick="showPage('register-domain')">+ Новый домен</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── MAIN ──────────────────────────────────────────────────────── -->
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- ════ PAGE: my-domains ════ -->
|
||||||
|
<div class="page active" id="page-my-domains">
|
||||||
|
|
||||||
|
<div class="kpi-row">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-icon blue">🌐</div>
|
||||||
|
<div><div class="kpi-val" id="kpi-total">–</div><div class="kpi-label">Доменов всего</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-icon green">✅</div>
|
||||||
|
<div><div class="kpi-val" id="kpi-active">–</div><div class="kpi-label">Активных</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-icon orange">⏳</div>
|
||||||
|
<div><div class="kpi-val" id="kpi-pending">–</div><div class="kpi-label">На проверке</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec-title">🌐 Зарегистрированные домены <button class="btn-sm" style="margin-left:10px;" onclick="window.reloadDomains && window.reloadDomains()">⟳ Обновить</button></div>
|
||||||
|
<div class="domains-grid" id="domains-grid">
|
||||||
|
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-my-domains -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: register-domain ════ -->
|
||||||
|
<div class="page" id="page-register-domain">
|
||||||
|
|
||||||
|
<div style="max-width:580px;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Зарегистрировать новый домен</div>
|
||||||
|
|
||||||
|
<!-- Domain search -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Желаемое имя домена</label>
|
||||||
|
<div class="reg-search-row">
|
||||||
|
<input class="reg-input" id="reg-name-input" type="text"
|
||||||
|
placeholder="название-проекта" autocomplete="off"
|
||||||
|
oninput="clearRegResult()" />
|
||||||
|
<span class="reg-suffix">.kt</span>
|
||||||
|
<button class="reg-check-btn" onclick="checkRegDomain()">Проверить</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">Латинские буквы, цифры и дефис. Не менее 3 символов.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-result" id="reg-result"></div>
|
||||||
|
|
||||||
|
<!-- Type -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Тип домена</label>
|
||||||
|
<select id="reg-type" onchange="updateTypeHint()">
|
||||||
|
<option value="personal">Персональный (по CDN — авто)</option>
|
||||||
|
<option value="project" selected>Проектный (требует одобрения IT)</option>
|
||||||
|
</select>
|
||||||
|
<div class="field-hint" id="type-hint" style="color:var(--orange);">
|
||||||
|
Проектные домены проходят проверку IT-администратором (1–2 рабочих дня).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Purpose -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Назначение / описание</label>
|
||||||
|
<textarea id="reg-purpose" placeholder="Кратко опишите для чего нужен домен (для IT-администратора)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DNS type -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Тип DNS-записи</label>
|
||||||
|
<select id="reg-dns-type">
|
||||||
|
<option>A</option>
|
||||||
|
<option>CNAME</option>
|
||||||
|
<option>MX</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Целевой IP / хост</label>
|
||||||
|
<input type="text" id="reg-dns-target" placeholder="10.0.0.1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" style="width:100%;padding:11px;" onclick="submitRegistration()">
|
||||||
|
Подать заявку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-register-domain -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: history ════ -->
|
||||||
|
<div class="page" id="page-history">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">История заявок</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:.83rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:2px solid var(--border);">
|
||||||
|
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Домен</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Тип</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Дата</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-tbody"><!-- populated by JS --></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-history -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: profile ════ -->
|
||||||
|
<div class="page" id="page-profile">
|
||||||
|
<div class="profile-grid">
|
||||||
|
<div class="profile-aside">
|
||||||
|
<div class="big-avatar" id="profile-avatar">АТ</div>
|
||||||
|
<strong id="profile-fullname">Аскар Тулеев</strong>
|
||||||
|
<span style="font-size:.76rem;color:var(--text-muted);" id="profile-cdn-display">CDN: tuleev.a</span>
|
||||||
|
<span class="status-pill active" style="margin-top:4px;">Активный сотрудник</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Личные данные</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field"><label>Имя</label><input type="text" id="p-name" value="Аскар" /></div>
|
||||||
|
<div class="field"><label>Фамилия</label><input type="text" id="p-surname" value="Тулеев" /></div>
|
||||||
|
<div class="field"><label>CDN</label><input type="text" id="p-cdn" value="tuleev.a" readonly style="background:#f9f9f9;color:var(--text-muted);" /></div>
|
||||||
|
<div class="field"><label>Корп. email</label><input type="email" id="p-email" value="tuleev.a@telecom.kz" /></div>
|
||||||
|
<div class="field" style="grid-column:1/-1;"><label>Отдел</label><input type="text" id="p-dept" value="Департамент цифровых продуктов" /></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" style="margin-top:6px;" onclick="toast('Данные сохранены','green')">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-profile -->
|
||||||
|
|
||||||
|
<!-- ════ PAGE: settings ════ -->
|
||||||
|
<div class="page" id="page-settings">
|
||||||
|
<div class="card" style="max-width:540px;">
|
||||||
|
<div class="card-title">Настройки уведомлений</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||||
|
<div><strong style="font-size:.88rem;">Email при смене статуса заявки</strong><br><span style="font-size:.76rem;color:var(--text-muted);">Одобрено / Отклонено / Конфликт</span></div>
|
||||||
|
<label style="position:relative;width:40px;height:22px;flex-shrink:0;">
|
||||||
|
<input type="checkbox" checked style="opacity:0;width:0;height:0;">
|
||||||
|
<span style="position:absolute;inset:0;background:var(--blue);border-radius:22px;cursor:pointer;transition:background .2s;"></span>
|
||||||
|
<span style="position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:3px;left:3px;transition:transform .2s;transform:translateX(18px);box-shadow:0 1px 3px rgba(0,0,0,.2);"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||||
|
<div><strong style="font-size:.88rem;">Напоминание о продлении</strong><br><span style="font-size:.76rem;color:var(--text-muted);">За 7 дней до истечения срока домена</span></div>
|
||||||
|
<label style="position:relative;width:40px;height:22px;flex-shrink:0;">
|
||||||
|
<input type="checkbox" checked style="opacity:0;width:0;height:0;">
|
||||||
|
<span style="position:absolute;inset:0;background:var(--blue);border-radius:22px;cursor:pointer;"></span>
|
||||||
|
<span style="position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:3px;left:3px;transform:translateX(18px);box-shadow:0 1px 3px rgba(0,0,0,.2);"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;">
|
||||||
|
<div><strong style="font-size:.88rem;">Push при конфликте домена</strong><br><span style="font-size:.76rem;color:var(--text-muted);">Если IT заблокировал или переназначил</span></div>
|
||||||
|
<label style="position:relative;width:40px;height:22px;flex-shrink:0;">
|
||||||
|
<input type="checkbox" style="opacity:0;width:0;height:0;">
|
||||||
|
<span style="position:absolute;inset:0;background:var(--border);border-radius:22px;cursor:pointer;"></span>
|
||||||
|
<span style="position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:3px;left:3px;box-shadow:0 1px 3px rgba(0,0,0,.2);"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /page-settings -->
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ── CHANGE DOMAIN MODAL ────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="modal-change">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Сменить имя домена</div>
|
||||||
|
<div class="modal-sub">Текущий домен: <strong id="modal-current-domain"></strong></div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Новое имя домена</label>
|
||||||
|
<div class="reg-search-row" style="margin-bottom:8px;">
|
||||||
|
<input class="reg-input" id="modal-new-name" type="text" placeholder="новое-имя" oninput="clearModalResult()" />
|
||||||
|
<span class="reg-suffix">.kt</span>
|
||||||
|
<button class="reg-check-btn" onclick="checkModalDomain()">Проверить</button>
|
||||||
|
</div>
|
||||||
|
<div class="reg-result" id="modal-result"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" onclick="closeModal()">Отмена</button>
|
||||||
|
<button class="btn-confirm" onclick="applyChange()">Применить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TOAST ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { api, requireAuth, logout, formatDate } from './app.js';
|
||||||
|
|
||||||
|
const user = requireAuth();
|
||||||
|
if (!user) throw new Error('redirecting');
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
const link = document.getElementById('nav-admin-link');
|
||||||
|
if (link) link.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_TITLES = {
|
||||||
|
'my-domains': 'Мои домены',
|
||||||
|
'register-domain': 'Зарегистрировать домен',
|
||||||
|
'history': 'История заявок',
|
||||||
|
'profile': 'Профиль',
|
||||||
|
'settings': 'Настройки',
|
||||||
|
};
|
||||||
|
|
||||||
|
function showPage(id) {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
const pg = document.getElementById('page-' + id);
|
||||||
|
if (pg) { pg.classList.add('active'); window.scrollTo({ top:0, behavior:'smooth' }); }
|
||||||
|
const ni = document.querySelector(`.nav-item[data-page="${id}"]`);
|
||||||
|
if (ni) ni.classList.add('active');
|
||||||
|
document.getElementById('header-title').textContent = PAGE_TITLES[id] || id;
|
||||||
|
if (id === 'my-domains' || id === 'history') reloadDomains();
|
||||||
|
if (id === 'profile') loadProfile();
|
||||||
|
}
|
||||||
|
window.showPage = showPage;
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||||
|
item.addEventListener('click', e => { e.preventDefault(); showPage(item.dataset.page); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// hook logout
|
||||||
|
const logoutBtn = document.querySelector('.logout-btn');
|
||||||
|
if (logoutBtn) logoutBtn.onclick = () => logout();
|
||||||
|
|
||||||
|
// ── Sidebar ─────────────────────────────────────────────────────
|
||||||
|
const cdn = user.cdn;
|
||||||
|
const initials = (cdn[0] + (cdn.split('.')[1] || '')[0] || '').toUpperCase();
|
||||||
|
document.getElementById('sidebar-cdn').textContent = cdn;
|
||||||
|
document.getElementById('sidebar-avatar').textContent = initials;
|
||||||
|
document.getElementById('profile-cdn-display').textContent = 'CDN: ' + cdn;
|
||||||
|
const pCdn = document.getElementById('p-cdn'); if (pCdn) pCdn.value = cdn;
|
||||||
|
|
||||||
|
// ── Render user domains ─────────────────────────────────────────
|
||||||
|
const STATUS_LABEL = { active:'Активен', pending:'На проверке', conflict:'Конфликт', inactive:'Отклонён' };
|
||||||
|
const TYPE_LABEL = { personal:'Персональный · автоматически по CDN', project:'Проектный · по заявке' };
|
||||||
|
|
||||||
|
async function reloadDomains() {
|
||||||
|
const grid = document.getElementById('domains-grid');
|
||||||
|
grid.innerHTML = '<div style="padding:20px;color:var(--text-muted);">Загрузка...</div>';
|
||||||
|
try {
|
||||||
|
const list = await api('/domains');
|
||||||
|
renderKpi(list);
|
||||||
|
renderGrid(list);
|
||||||
|
renderHistory(list);
|
||||||
|
} catch (e) {
|
||||||
|
grid.innerHTML = `<div style="padding:20px;color:var(--red,#c33);">Ошибка: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.reloadDomains = reloadDomains;
|
||||||
|
|
||||||
|
function renderKpi(list) {
|
||||||
|
const total = list.length;
|
||||||
|
const active = list.filter(d => d.status === 'active').length;
|
||||||
|
const pending = list.filter(d => d.status === 'pending').length;
|
||||||
|
document.getElementById('kpi-total').textContent = total;
|
||||||
|
document.getElementById('kpi-active').textContent = active;
|
||||||
|
document.getElementById('kpi-pending').textContent = pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(list) {
|
||||||
|
const grid = document.getElementById('domains-grid');
|
||||||
|
if (!list.length) {
|
||||||
|
grid.innerHTML = `<div style="padding:20px;color:var(--text-muted);">Пока нет доменов. Открой "Зарегистрировать домен".</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = list.map(d => {
|
||||||
|
const icon = d.type === 'personal' ? '🏠' : (d.status === 'pending' ? '📋' : '🗂️');
|
||||||
|
const statusLabel = STATUS_LABEL[d.status] || d.status;
|
||||||
|
const typeLabel = TYPE_LABEL[d.type] || d.type;
|
||||||
|
const ip = d.dns_record || '—';
|
||||||
|
const created = formatDate(d.created_at);
|
||||||
|
return `
|
||||||
|
<div class="domain-card">
|
||||||
|
<div class="domain-card__head">
|
||||||
|
<div class="domain-icon">${icon}</div>
|
||||||
|
<div>
|
||||||
|
<div class="domain-name">${escapeHtml(d.name)}<span class="kt">.kt</span></div>
|
||||||
|
<div class="domain-type">${typeLabel}</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill ${d.status}">${statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div class="domain-card__body">
|
||||||
|
<div class="info-row"><span class="info-row__label">Полный адрес</span><span class="info-row__val">${escapeHtml(d.fqdn)}</span></div>
|
||||||
|
<div class="info-row"><span class="info-row__label">CDN владельца</span><span class="info-row__val">${escapeHtml(cdn)}</span></div>
|
||||||
|
<div class="info-row"><span class="info-row__label">Зарегистрирован</span><span class="info-row__val">${created}</span></div>
|
||||||
|
<div class="info-row"><span class="info-row__label">DNS-запись</span><span class="info-row__val">A ${escapeHtml(ip)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory(list) {
|
||||||
|
const tbody = document.getElementById('history-tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = list.map(d => `
|
||||||
|
<tr style="border-bottom:1px solid var(--border);">
|
||||||
|
<td style="padding:11px 12px;font-weight:600;font-family:monospace;">${escapeHtml(d.fqdn)}</td>
|
||||||
|
<td style="padding:11px 12px;">${d.type === 'personal' ? 'Персональный' : 'Проектный'}</td>
|
||||||
|
<td style="padding:11px 12px;color:var(--text-muted);">${formatDate(d.created_at)}</td>
|
||||||
|
<td style="padding:11px 12px;"><span class="status-pill ${d.status}">${STATUS_LABEL[d.status] || d.status}</span></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Domain check (register page) ────────────────────────────────
|
||||||
|
let lastCheck = null;
|
||||||
|
window.checkRegDomain = async function() {
|
||||||
|
const raw = document.getElementById('reg-name-input').value.trim().toLowerCase().replace(/[^a-z0-9-]/g,'');
|
||||||
|
if (!raw || raw.length < 2) { toast('Введите имя домена (минимум 2 символа)'); return; }
|
||||||
|
const el = document.getElementById('reg-result');
|
||||||
|
el.className = 'reg-result'; el.textContent = 'Проверяю...';
|
||||||
|
try {
|
||||||
|
const r = await api('/domains/check?name=' + encodeURIComponent(raw));
|
||||||
|
lastCheck = r;
|
||||||
|
if (r.available) {
|
||||||
|
el.className = 'reg-result free';
|
||||||
|
el.textContent = `✅ ${raw}.kt — свободен! Можно регистрировать.`;
|
||||||
|
} else {
|
||||||
|
el.className = 'reg-result taken';
|
||||||
|
const hint = r.reason === 'taken' && r.suggestions?.length
|
||||||
|
? ` Предложения: ${r.suggestions.join(', ')}`
|
||||||
|
: (r.reason ? ` (${r.reason})` : '');
|
||||||
|
el.textContent = `❌ ${raw}.kt — недоступен.${hint}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
el.className = 'reg-result taken';
|
||||||
|
el.textContent = `Ошибка проверки: ${e.message}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.clearRegResult = function() { document.getElementById('reg-result').className = 'reg-result'; };
|
||||||
|
|
||||||
|
window.updateTypeHint = function() {
|
||||||
|
const type = document.getElementById('reg-type').value;
|
||||||
|
const hint = document.getElementById('type-hint');
|
||||||
|
if (type === 'project') {
|
||||||
|
hint.textContent = 'Проектные домены проходят проверку IT-администратором.';
|
||||||
|
hint.style.color = 'var(--orange)';
|
||||||
|
} else {
|
||||||
|
hint.textContent = 'Персональный домен формируется автоматически из вашего CDN.';
|
||||||
|
hint.style.color = 'var(--blue)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.submitRegistration = async function() {
|
||||||
|
const type = document.getElementById('reg-type').value;
|
||||||
|
const name = document.getElementById('reg-name-input').value.trim().toLowerCase();
|
||||||
|
const purpose = document.getElementById('reg-purpose').value.trim();
|
||||||
|
const dnsTarget = document.getElementById('reg-dns-target').value.trim();
|
||||||
|
|
||||||
|
if (type === 'project' && !name) { toast('Введите имя домена'); return; }
|
||||||
|
try {
|
||||||
|
const r = await api('/domains/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type, name: type === 'project' ? name : undefined, purpose, dns: dnsTarget },
|
||||||
|
});
|
||||||
|
const msg = type === 'personal'
|
||||||
|
? 'Процесс регистрации запущен в Zorro. Домен появится через несколько секунд.'
|
||||||
|
: `Заявка на ${name}.kt отправлена IT-администратору.`;
|
||||||
|
toast(msg, 'green');
|
||||||
|
document.getElementById('reg-name-input').value = '';
|
||||||
|
document.getElementById('reg-purpose').value = '';
|
||||||
|
document.getElementById('reg-dns-target').value = '';
|
||||||
|
window.clearRegResult();
|
||||||
|
setTimeout(() => showPage('my-domains'), 1500);
|
||||||
|
// reload a couple of times to catch the background pipeline
|
||||||
|
setTimeout(reloadDomains, 4000);
|
||||||
|
setTimeout(reloadDomains, 9000);
|
||||||
|
} catch (e) {
|
||||||
|
toast(`Ошибка: ${e.message}`, 'red');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Profile ──────────────────────────────────────────────────────
|
||||||
|
async function loadProfile() {
|
||||||
|
try {
|
||||||
|
const me = await api('/auth/me');
|
||||||
|
const name = document.getElementById('p-name'); if (name) name.value = (me.name || '').split(' ')[0] || '';
|
||||||
|
const surname = document.getElementById('p-surname'); if (surname) surname.value = (me.name || '').split(' ').slice(1).join(' ') || '';
|
||||||
|
const email = document.getElementById('p-email'); if (email) email.value = me.email || '';
|
||||||
|
const dept = document.getElementById('p-dept'); if (dept) dept.value = me.department || '';
|
||||||
|
const ff = document.getElementById('profile-fullname'); if (ff) ff.textContent = me.name;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change / Delete — not implemented in MVP ────────────────────
|
||||||
|
window.openChangeDomain = (d) => toast(`Смена имени пока не реализована для ${d}`, 'orange');
|
||||||
|
window.closeModal = () => document.getElementById('modal-change').classList.remove('open');
|
||||||
|
window.checkModalDomain = () => {};
|
||||||
|
window.clearModalResult = () => {};
|
||||||
|
window.applyChange = () => window.closeModal();
|
||||||
|
window.confirmDelete = (d) => toast(`Удаление пока не реализовано для ${d}`, 'orange');
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────
|
||||||
|
let toastTimer;
|
||||||
|
window.toast = function(msg, type = '') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = 'toast show' + (type ? ' ' + type : '');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => { t.className = 'toast'; }, 3500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Initial load ─────────────────────────────────────────────────
|
||||||
|
reloadDomains();
|
||||||
|
loadProfile();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
455
domain-registration.bpmn
Normal file
455
domain-registration.bpmn
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<definitions
|
||||||
|
xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:zeebe="http://camunda.org/schema/zeebe/1.0"
|
||||||
|
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||||
|
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||||
|
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||||
|
id="Definitions_KT_Domain"
|
||||||
|
targetNamespace="http://kt.telecom/bpmn"
|
||||||
|
exporter="Camunda Modeler"
|
||||||
|
exporterVersion="5.x">
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
PROCESS — Domain Registration (MVP)
|
||||||
|
ID: kt_domain_registration
|
||||||
|
Desc: Регистрация домена второго уровня в зоне .kt.
|
||||||
|
CDN → имя домена → проверка → авто/ручное одобрение → PowerDNS → email
|
||||||
|
════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<process
|
||||||
|
id="kt_domain_registration"
|
||||||
|
name="Регистрация домена .kt"
|
||||||
|
isExecutable="true">
|
||||||
|
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:userTaskForm id="form_admin_review">
|
||||||
|
{
|
||||||
|
"schemaVersion": 10,
|
||||||
|
"id": "admin_review_form",
|
||||||
|
"type": "default",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "### Заявка на регистрацию домена\nПроверьте данные и примите решение."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"key": "adminDecision",
|
||||||
|
"label": "Решение",
|
||||||
|
"values": [
|
||||||
|
{ "label": "Одобрить", "value": "APPROVE" },
|
||||||
|
{ "label": "Отклонить", "value": "REJECT" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "textfield",
|
||||||
|
"key": "adminComment",
|
||||||
|
"label": "Комментарий",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</zeebe:userTaskForm>
|
||||||
|
</extensionElements>
|
||||||
|
|
||||||
|
<!-- START -->
|
||||||
|
<startEvent id="start" name="Сотрудник подал заявку">
|
||||||
|
<outgoing>flow_start_to_validate</outgoing>
|
||||||
|
</startEvent>
|
||||||
|
|
||||||
|
<!-- TASK 1: Validate CDN and build domain name
|
||||||
|
Input: employeeCdn (e.g. "tuleev.a"), domainType ("personal"|"project"), requestedName
|
||||||
|
Output: isValid (bool), finalDomainName (e.g. "tuleev" or "devops-team")
|
||||||
|
Logic:
|
||||||
|
personal → берём часть до точки в CDN: "tuleev.a" → "tuleev"
|
||||||
|
project → используем requestedName как есть
|
||||||
|
Зона .kt добавляется только в шаге pdns.createRecord -->
|
||||||
|
<serviceTask id="task_validate_and_build"
|
||||||
|
name="Проверить CDN и сформировать имя домена">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="employee.validateAndBuild" retries="3" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=employeeCdn" target="cdn" />
|
||||||
|
<zeebe:input source="=domainType" target="domainType" />
|
||||||
|
<zeebe:input source="=requestedName" target="requestedName" />
|
||||||
|
<zeebe:output source="=isValid" target="isValid" />
|
||||||
|
<zeebe:output source="=finalDomainName" target="finalDomainName" />
|
||||||
|
<zeebe:output source="=employeeFullName" target="employeeFullName" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_start_to_validate</incoming>
|
||||||
|
<outgoing>flow_validate_to_gw_valid</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<!-- GW1: CDN valid? -->
|
||||||
|
<exclusiveGateway id="gw_valid" name="CDN корректен?">
|
||||||
|
<incoming>flow_validate_to_gw_valid</incoming>
|
||||||
|
<outgoing>flow_valid_yes</outgoing>
|
||||||
|
<outgoing>flow_valid_no</outgoing>
|
||||||
|
</exclusiveGateway>
|
||||||
|
|
||||||
|
<endEvent id="end_invalid" name="CDN не найден. Заявка отклонена.">
|
||||||
|
<incoming>flow_valid_no</incoming>
|
||||||
|
</endEvent>
|
||||||
|
|
||||||
|
<!-- TASK 2: Check domain availability in registry -->
|
||||||
|
<serviceTask id="task_check_availability"
|
||||||
|
name="Проверить доступность домена в реестре .kt">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="domain.checkAvailability" retries="3" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
<zeebe:output source="=isAvailable" target="isAvailable" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_valid_yes</incoming>
|
||||||
|
<outgoing>flow_check_to_gw_avail</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<!-- GW2: Available? -->
|
||||||
|
<exclusiveGateway id="gw_available" name="Имя свободно?">
|
||||||
|
<incoming>flow_check_to_gw_avail</incoming>
|
||||||
|
<outgoing>flow_avail_yes</outgoing>
|
||||||
|
<outgoing>flow_avail_no</outgoing>
|
||||||
|
</exclusiveGateway>
|
||||||
|
|
||||||
|
<!-- Not available → notify employee → end -->
|
||||||
|
<serviceTask id="task_notify_unavailable"
|
||||||
|
name="Уведомить: имя занято">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="notification.sendUnavailable" retries="2" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=employeeEmail" target="email" />
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_avail_no</incoming>
|
||||||
|
<outgoing>flow_unavail_to_end</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<endEvent id="end_unavailable" name="Имя занято. Сотрудник уведомлён.">
|
||||||
|
<incoming>flow_unavail_to_end</incoming>
|
||||||
|
</endEvent>
|
||||||
|
|
||||||
|
<!-- GW3: Domain type — personal (auto) or project (admin review) -->
|
||||||
|
<exclusiveGateway id="gw_type" name="Тип домена?">
|
||||||
|
<incoming>flow_avail_yes</incoming>
|
||||||
|
<outgoing>flow_type_personal</outgoing>
|
||||||
|
<outgoing>flow_type_project</outgoing>
|
||||||
|
</exclusiveGateway>
|
||||||
|
|
||||||
|
<!-- TASK: Auto-approve personal domain (save to registry) -->
|
||||||
|
<serviceTask id="task_auto_approve"
|
||||||
|
name="Авто-одобрение: зарегистрировать персональный домен">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="domain.autoApprove" retries="2" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
<zeebe:input source="=employeeCdn" target="cdn" />
|
||||||
|
<zeebe:output source="=domainId" target="domainId" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_type_personal</incoming>
|
||||||
|
<outgoing>flow_auto_to_dns</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<!-- USER TASK: IT admin reviews project domain request -->
|
||||||
|
<userTask id="utask_admin_review"
|
||||||
|
name="IT-администратор рассматривает заявку на проектный домен">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:formDefinition formKey="camunda-forms:bpmn:form_admin_review" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
<zeebe:input source="=employeeCdn" target="cdn" />
|
||||||
|
<zeebe:input source="=employeeFullName" target="fullName" />
|
||||||
|
<zeebe:input source="=purpose" target="purpose" />
|
||||||
|
<zeebe:output source="=adminDecision" target="adminDecision" />
|
||||||
|
<zeebe:output source="=adminComment" target="adminComment" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
<zeebe:assignmentDefinition candidateGroups="kt_domain_admins" />
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_type_project</incoming>
|
||||||
|
<outgoing>flow_admin_to_gw</outgoing>
|
||||||
|
</userTask>
|
||||||
|
|
||||||
|
<!-- GW4: Admin decision -->
|
||||||
|
<exclusiveGateway id="gw_admin_decision" name="Решение администратора">
|
||||||
|
<incoming>flow_admin_to_gw</incoming>
|
||||||
|
<outgoing>flow_admin_approve</outgoing>
|
||||||
|
<outgoing>flow_admin_reject</outgoing>
|
||||||
|
</exclusiveGateway>
|
||||||
|
|
||||||
|
<!-- Rejected → notify → end -->
|
||||||
|
<serviceTask id="task_notify_rejected"
|
||||||
|
name="Уведомить об отклонении заявки">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="notification.sendRejection" retries="2" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=employeeEmail" target="email" />
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
<zeebe:input source="=adminComment" target="reason" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_admin_reject</incoming>
|
||||||
|
<outgoing>flow_rejected_to_end</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<endEvent id="end_rejected" name="Заявка отклонена. Сотрудник уведомлён.">
|
||||||
|
<incoming>flow_rejected_to_end</incoming>
|
||||||
|
</endEvent>
|
||||||
|
|
||||||
|
<!-- Merge approved paths (personal auto + project approved) -->
|
||||||
|
<exclusiveGateway id="gw_merge" name="Домен одобрен">
|
||||||
|
<incoming>flow_auto_to_dns</incoming>
|
||||||
|
<incoming>flow_admin_approve</incoming>
|
||||||
|
<outgoing>flow_merge_to_dns</outgoing>
|
||||||
|
</exclusiveGateway>
|
||||||
|
|
||||||
|
<!-- TASK: Create DNS record via PowerDNS API
|
||||||
|
Creates an A or CNAME record for finalDomainName.kt in PowerDNS -->
|
||||||
|
<serviceTask id="task_dns"
|
||||||
|
name="Создать DNS-запись в PowerDNS (зона .kt)">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="pdns.createRecord" retries="5" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
<zeebe:input source="=dnsRecordType" target="recordType" />
|
||||||
|
<zeebe:input source="=dnsTarget" target="target" />
|
||||||
|
<zeebe:output source="=dnsStatus" target="dnsStatus" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_merge_to_dns</incoming>
|
||||||
|
<outgoing>flow_dns_to_notify</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<!-- TASK: Notify employee — domain is active -->
|
||||||
|
<serviceTask id="task_notify_active"
|
||||||
|
name="Отправить email: домен активирован">
|
||||||
|
<extensionElements>
|
||||||
|
<zeebe:taskDefinition type="notification.sendActivation" retries="3" />
|
||||||
|
<zeebe:ioMapping>
|
||||||
|
<zeebe:input source="=employeeEmail" target="email" />
|
||||||
|
<zeebe:input source="=employeeFullName" target="fullName" />
|
||||||
|
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||||
|
</zeebe:ioMapping>
|
||||||
|
</extensionElements>
|
||||||
|
<incoming>flow_dns_to_notify</incoming>
|
||||||
|
<outgoing>flow_notify_to_end</outgoing>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<!-- END: Domain active -->
|
||||||
|
<endEvent id="end_active" name="Домен активен. Сотрудник уведомлён.">
|
||||||
|
<incoming>flow_notify_to_end</incoming>
|
||||||
|
</endEvent>
|
||||||
|
|
||||||
|
<!-- ════ SEQUENCE FLOWS ════ -->
|
||||||
|
<sequenceFlow id="flow_start_to_validate" sourceRef="start" targetRef="task_validate_and_build" />
|
||||||
|
<sequenceFlow id="flow_validate_to_gw_valid" sourceRef="task_validate_and_build" targetRef="gw_valid" />
|
||||||
|
<sequenceFlow id="flow_valid_yes" name="Да" sourceRef="gw_valid" targetRef="task_check_availability">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=isValid = true</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_valid_no" name="Нет" sourceRef="gw_valid" targetRef="end_invalid">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=isValid = false</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_check_to_gw_avail" sourceRef="task_check_availability" targetRef="gw_available" />
|
||||||
|
<sequenceFlow id="flow_avail_yes" name="Свободен" sourceRef="gw_available" targetRef="gw_type">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=isAvailable = true</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_avail_no" name="Занято" sourceRef="gw_available" targetRef="task_notify_unavailable">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=isAvailable = false</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_unavail_to_end" sourceRef="task_notify_unavailable" targetRef="end_unavailable" />
|
||||||
|
<sequenceFlow id="flow_type_personal" name="Персональный" sourceRef="gw_type" targetRef="task_auto_approve">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=domainType = "personal"</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_type_project" name="Проектный" sourceRef="gw_type" targetRef="utask_admin_review">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=domainType = "project"</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_auto_to_dns" sourceRef="task_auto_approve" targetRef="gw_merge" />
|
||||||
|
<sequenceFlow id="flow_admin_to_gw" sourceRef="utask_admin_review" targetRef="gw_admin_decision" />
|
||||||
|
<sequenceFlow id="flow_admin_approve" name="APPROVE" sourceRef="gw_admin_decision" targetRef="gw_merge">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=adminDecision = "APPROVE"</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_admin_reject" name="REJECT" sourceRef="gw_admin_decision" targetRef="task_notify_rejected">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression">=adminDecision = "REJECT"</conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow_rejected_to_end" sourceRef="task_notify_rejected" targetRef="end_rejected" />
|
||||||
|
<sequenceFlow id="flow_merge_to_dns" sourceRef="gw_merge" targetRef="task_dns" />
|
||||||
|
<sequenceFlow id="flow_dns_to_notify" sourceRef="task_dns" targetRef="task_notify_active" />
|
||||||
|
<sequenceFlow id="flow_notify_to_end" sourceRef="task_notify_active" targetRef="end_active" />
|
||||||
|
|
||||||
|
</process>
|
||||||
|
|
||||||
|
<!-- ════ BPMN DIAGRAM LAYOUT ════ -->
|
||||||
|
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||||
|
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="kt_domain_registration">
|
||||||
|
|
||||||
|
<!-- Start -->
|
||||||
|
<bpmndi:BPMNShape id="shape_start" bpmnElement="start">
|
||||||
|
<dc:Bounds x="152" y="242" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="126" y="285" width="90" height="27" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task 1: validate and build -->
|
||||||
|
<bpmndi:BPMNShape id="shape_validate" bpmnElement="task_validate_and_build">
|
||||||
|
<dc:Bounds x="240" y="220" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- GW1: valid? -->
|
||||||
|
<bpmndi:BPMNShape id="shape_gw_valid" bpmnElement="gw_valid" isMarkerVisible="true">
|
||||||
|
<dc:Bounds x="455" y="235" width="50" height="50" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="441" y="292" width="78" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- End: invalid CDN -->
|
||||||
|
<bpmndi:BPMNShape id="shape_end_invalid" bpmnElement="end_invalid">
|
||||||
|
<dc:Bounds x="462" y="142" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="436" y="112" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task 2: check availability -->
|
||||||
|
<bpmndi:BPMNShape id="shape_check" bpmnElement="task_check_availability">
|
||||||
|
<dc:Bounds x="560" y="220" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- GW2: available? -->
|
||||||
|
<bpmndi:BPMNShape id="shape_gw_avail" bpmnElement="gw_available" isMarkerVisible="true">
|
||||||
|
<dc:Bounds x="775" y="235" width="50" height="50" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="762" y="292" width="76" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task: notify unavailable -->
|
||||||
|
<bpmndi:BPMNShape id="shape_notify_unavail" bpmnElement="task_notify_unavailable">
|
||||||
|
<dc:Bounds x="750" y="360" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- End: unavailable -->
|
||||||
|
<bpmndi:BPMNShape id="shape_end_unavail" bpmnElement="end_unavailable">
|
||||||
|
<dc:Bounds x="832" y="482" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="806" y="525" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- GW3: domain type -->
|
||||||
|
<bpmndi:BPMNShape id="shape_gw_type" bpmnElement="gw_type" isMarkerVisible="true">
|
||||||
|
<dc:Bounds x="895" y="235" width="50" height="50" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="887" y="292" width="66" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task: auto approve -->
|
||||||
|
<bpmndi:BPMNShape id="shape_auto" bpmnElement="task_auto_approve">
|
||||||
|
<dc:Bounds x="1000" y="140" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- User Task: admin review -->
|
||||||
|
<bpmndi:BPMNShape id="shape_admin_review" bpmnElement="utask_admin_review">
|
||||||
|
<dc:Bounds x="1000" y="300" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- GW4: admin decision -->
|
||||||
|
<bpmndi:BPMNShape id="shape_gw_admin" bpmnElement="gw_admin_decision" isMarkerVisible="true">
|
||||||
|
<dc:Bounds x="1215" y="315" width="50" height="50" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="1195" y="372" width="90" height="27" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task: notify rejected -->
|
||||||
|
<bpmndi:BPMNShape id="shape_notify_rejected" bpmnElement="task_notify_rejected">
|
||||||
|
<dc:Bounds x="1190" y="430" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- End: rejected -->
|
||||||
|
<bpmndi:BPMNShape id="shape_end_rejected" bpmnElement="end_rejected">
|
||||||
|
<dc:Bounds x="1272" y="552" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="1246" y="595" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- GW merge -->
|
||||||
|
<bpmndi:BPMNShape id="shape_gw_merge" bpmnElement="gw_merge" isMarkerVisible="true">
|
||||||
|
<dc:Bounds x="1335" y="235" width="50" height="50" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="1319" y="292" width="82" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task: PowerDNS -->
|
||||||
|
<bpmndi:BPMNShape id="shape_dns" bpmnElement="task_dns">
|
||||||
|
<dc:Bounds x="1450" y="220" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Task: notify active -->
|
||||||
|
<bpmndi:BPMNShape id="shape_notify_active" bpmnElement="task_notify_active">
|
||||||
|
<dc:Bounds x="1670" y="220" width="160" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- End: active -->
|
||||||
|
<bpmndi:BPMNShape id="shape_end_active" bpmnElement="end_active">
|
||||||
|
<dc:Bounds x="1892" y="242" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="1866" y="285" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
|
||||||
|
<!-- Edges -->
|
||||||
|
<bpmndi:BPMNEdge id="edge_start_to_validate" bpmnElement="flow_start_to_validate">
|
||||||
|
<di:waypoint x="188" y="260" /><di:waypoint x="240" y="260" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_validate_to_gw" bpmnElement="flow_validate_to_gw_valid">
|
||||||
|
<di:waypoint x="400" y="260" /><di:waypoint x="455" y="260" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_valid_no" bpmnElement="flow_valid_no">
|
||||||
|
<di:waypoint x="480" y="235" /><di:waypoint x="480" y="178" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="487" y="202" width="26" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_valid_yes" bpmnElement="flow_valid_yes">
|
||||||
|
<di:waypoint x="505" y="260" /><di:waypoint x="560" y="260" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="524" y="242" width="18" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_check_to_gw" bpmnElement="flow_check_to_gw_avail">
|
||||||
|
<di:waypoint x="720" y="260" /><di:waypoint x="775" y="260" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_avail_no" bpmnElement="flow_avail_no">
|
||||||
|
<di:waypoint x="800" y="285" /><di:waypoint x="800" y="360" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="808" y="315" width="42" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_unavail_to_end" bpmnElement="flow_unavail_to_end">
|
||||||
|
<di:waypoint x="830" y="440" /><di:waypoint x="850" y="482" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_avail_yes" bpmnElement="flow_avail_yes">
|
||||||
|
<di:waypoint x="825" y="260" /><di:waypoint x="895" y="260" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="843" y="242" width="52" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_type_personal" bpmnElement="flow_type_personal">
|
||||||
|
<di:waypoint x="920" y="235" /><di:waypoint x="920" y="180" /><di:waypoint x="1000" y="180" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="926" y="200" width="66" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_type_project" bpmnElement="flow_type_project">
|
||||||
|
<di:waypoint x="920" y="285" /><di:waypoint x="920" y="340" /><di:waypoint x="1000" y="340" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="926" y="305" width="52" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_auto_to_merge" bpmnElement="flow_auto_to_dns">
|
||||||
|
<di:waypoint x="1160" y="180" /><di:waypoint x="1360" y="180" /><di:waypoint x="1360" y="235" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_admin_to_gw" bpmnElement="flow_admin_to_gw">
|
||||||
|
<di:waypoint x="1160" y="340" /><di:waypoint x="1215" y="340" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_admin_approve" bpmnElement="flow_admin_approve">
|
||||||
|
<di:waypoint x="1240" y="315" /><di:waypoint x="1240" y="260" /><di:waypoint x="1335" y="260" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="1267" y="282" width="46" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_admin_reject" bpmnElement="flow_admin_reject">
|
||||||
|
<di:waypoint x="1240" y="365" /><di:waypoint x="1240" y="430" />
|
||||||
|
<bpmndi:BPMNLabel><dc:Bounds x="1248" y="390" width="34" height="14" /></bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_rejected_to_end" bpmnElement="flow_rejected_to_end">
|
||||||
|
<di:waypoint x="1270" y="510" /><di:waypoint x="1290" y="552" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_merge_to_dns" bpmnElement="flow_merge_to_dns">
|
||||||
|
<di:waypoint x="1385" y="260" /><di:waypoint x="1450" y="260" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_dns_to_notify" bpmnElement="flow_dns_to_notify">
|
||||||
|
<di:waypoint x="1610" y="260" /><di:waypoint x="1670" y="260" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="edge_notify_to_end" bpmnElement="flow_notify_to_end">
|
||||||
|
<di:waypoint x="1830" y="260" /><di:waypoint x="1892" y="260" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
|
||||||
|
</definitions>
|
||||||
539
index.html
Normal file
539
index.html
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>domain.kt — Корпоративный регистратор доменов Казахтелеком</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--blue: #005baa;
|
||||||
|
--blue-dark: #003f7a;
|
||||||
|
--blue-light: #e8f0fb;
|
||||||
|
--blue-mid: #1a6fc4;
|
||||||
|
--green: #1ab36e;
|
||||||
|
--green-bg: #e6f9f0;
|
||||||
|
--orange: #f07200;
|
||||||
|
--red: #d93636;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--bg: #f3f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow: 0 2px 16px rgba(0,91,170,.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NAV ─────────────────────────────────────────────────── */
|
||||||
|
nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
background: rgba(0,63,122,.97);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 0 40px; height: 60px; gap: 32px;
|
||||||
|
}
|
||||||
|
.nav-logo {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
text-decoration: none; color: #fff; font-weight: 700; font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.nav-logo .dot-kt {
|
||||||
|
background: #60a5fa; color: var(--blue-dark);
|
||||||
|
font-size: .75rem; font-weight: 800;
|
||||||
|
padding: 2px 7px; border-radius: 6px; letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.nav-links { display: flex; gap: 24px; flex: 1; }
|
||||||
|
.nav-links a {
|
||||||
|
color: rgba(255,255,255,.75); text-decoration: none;
|
||||||
|
font-size: .88rem; transition: color .15s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: #fff; }
|
||||||
|
.nav-actions { display: flex; gap: 10px; }
|
||||||
|
.btn-outline {
|
||||||
|
padding: 7px 18px; border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,.4);
|
||||||
|
background: none; color: #fff; cursor: pointer;
|
||||||
|
font-size: .85rem; text-decoration: none;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.btn-outline:hover { border-color: #fff; background: rgba(255,255,255,.08); }
|
||||||
|
.btn-primary-sm {
|
||||||
|
padding: 7px 18px; border-radius: 8px;
|
||||||
|
background: #60a5fa; color: var(--blue-dark);
|
||||||
|
border: none; cursor: pointer; font-size: .85rem; font-weight: 700;
|
||||||
|
text-decoration: none; transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-primary-sm:hover { background: #93c5fd; }
|
||||||
|
|
||||||
|
/* ── HERO ────────────────────────────────────────────────── */
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(145deg, #002a5c 0%, #005baa 50%, #0077cc 100%);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
padding: 100px 24px 60px; text-align: center;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% 100%, rgba(96,165,250,.15) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* decorative grid */
|
||||||
|
.hero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
border: 1px solid rgba(255,255,255,.2);
|
||||||
|
border-radius: 20px; padding: 5px 14px;
|
||||||
|
color: #93c5fd; font-size: .78rem; font-weight: 600;
|
||||||
|
margin-bottom: 28px; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.hero-badge .pulse {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: #60a5fa; animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,100% { opacity:1; transform:scale(1); }
|
||||||
|
50% { opacity:.5; transform:scale(1.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||||
|
font-weight: 800; color: #fff; line-height: 1.15;
|
||||||
|
margin-bottom: 18px; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.hero h1 .accent { color: #60a5fa; }
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.05rem; color: rgba(255,255,255,.75);
|
||||||
|
max-width: 520px; line-height: 1.6;
|
||||||
|
margin-bottom: 44px; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SEARCH BOX ──────────────────────────────────────────── */
|
||||||
|
.search-box {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 100%; max-width: 640px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.search-row {
|
||||||
|
display: flex; background: #fff;
|
||||||
|
border-radius: 14px; overflow: hidden;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.search-suffix {
|
||||||
|
padding: 0 16px; background: var(--bg);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
color: var(--blue); font-size: 1rem; font-weight: 800;
|
||||||
|
border-left: 1px solid var(--border); white-space: nowrap;
|
||||||
|
flex-shrink: 0; letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
flex: 1; padding: 18px 16px; border: none; outline: none;
|
||||||
|
font-size: 1rem; color: var(--text); background: #fff;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.search-input::placeholder { color: #aaa; }
|
||||||
|
.search-btn {
|
||||||
|
padding: 0 28px; background: var(--blue); color: #fff;
|
||||||
|
border: none; cursor: pointer; font-size: .9rem; font-weight: 700;
|
||||||
|
transition: background .15s; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.search-btn:hover { background: var(--blue-dark); }
|
||||||
|
|
||||||
|
.search-hint {
|
||||||
|
color: rgba(255,255,255,.5); font-size: .78rem;
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* search result */
|
||||||
|
.search-result {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
margin-top: 16px; width: 100%; max-width: 640px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-result.show { display: block; }
|
||||||
|
.result-card {
|
||||||
|
background: #fff; border-radius: 12px; overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.result-row {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.result-row:last-child { border-bottom: none; }
|
||||||
|
.result-domain { font-weight: 700; font-size: .95rem; flex: 1; }
|
||||||
|
.result-domain span { color: var(--text-muted); font-weight: 400; }
|
||||||
|
.tag-free { background: var(--green-bg); color: var(--green); padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
|
||||||
|
.tag-taken { background: #fdeaea; color: var(--red); padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
|
||||||
|
.btn-claim {
|
||||||
|
padding: 7px 18px; border-radius: 8px; font-size: .82rem;
|
||||||
|
font-weight: 700; background: var(--blue); color: #fff;
|
||||||
|
border: none; cursor: pointer; text-decoration: none;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-claim:hover { background: var(--blue-dark); }
|
||||||
|
|
||||||
|
/* ── STATS ───────────────────────────────────────────────── */
|
||||||
|
.stats {
|
||||||
|
display: flex; gap: 48px; justify-content: center;
|
||||||
|
padding: 32px 24px 0; position: relative; z-index: 1; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat-item { text-align: center; }
|
||||||
|
.stat-val { font-size: 2rem; font-weight: 800; color: #fff; }
|
||||||
|
.stat-label { font-size: .78rem; color: rgba(255,255,255,.55); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── HOW IT WORKS ─────────────────────────────────────────── */
|
||||||
|
.section { padding: 80px 24px; }
|
||||||
|
.section-center { max-width: 1000px; margin: 0 auto; }
|
||||||
|
.section-label {
|
||||||
|
text-align: center; font-size: .75rem; text-transform: uppercase;
|
||||||
|
letter-spacing: .1em; color: var(--blue); font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
text-align: center; font-size: clamp(1.5rem, 3vw, 2.2rem);
|
||||||
|
font-weight: 800; margin-bottom: 48px; line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
.step-card {
|
||||||
|
background: var(--card); border-radius: var(--radius);
|
||||||
|
padding: 28px 24px; box-shadow: var(--shadow);
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
background: var(--blue-light); color: var(--blue);
|
||||||
|
font-weight: 800; font-size: 1.1rem;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.step-card h3 { font-size: 1rem; font-weight: 700; }
|
||||||
|
.step-card p { font-size: .85rem; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
.step-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.step-card::after {
|
||||||
|
content: '→';
|
||||||
|
position: absolute; right: -22px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 1.4rem; color: var(--border);
|
||||||
|
}
|
||||||
|
.steps > .step-card:last-child::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DOMAIN EXAMPLE ───────────────────────────────────────── */
|
||||||
|
.example-section {
|
||||||
|
background: var(--blue-dark);
|
||||||
|
padding: 72px 24px;
|
||||||
|
}
|
||||||
|
.example-section .section-title { color: #fff; }
|
||||||
|
.example-section .section-label { color: #60a5fa; }
|
||||||
|
|
||||||
|
.example-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px; max-width: 960px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.example-card {
|
||||||
|
background: rgba(255,255,255,.07);
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
border-radius: var(--radius); padding: 22px 20px;
|
||||||
|
}
|
||||||
|
.example-card__label { font-size: .72rem; color: rgba(255,255,255,.5); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.example-card__cdn { font-size: .85rem; color: #93c5fd; font-family: monospace; margin-bottom: 6px; }
|
||||||
|
.example-card__domain { font-size: 1.2rem; font-weight: 800; color: #fff; }
|
||||||
|
.example-card__domain .kt-part { color: #60a5fa; }
|
||||||
|
.example-card__note { font-size: .76rem; color: rgba(255,255,255,.5); margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ── FEATURES ─────────────────────────────────────────────── */
|
||||||
|
.features-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
background: var(--card); border-radius: var(--radius);
|
||||||
|
padding: 24px 20px; box-shadow: var(--shadow);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 44px; height: 44px; border-radius: 10px;
|
||||||
|
background: var(--blue-light); display: flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
.feature-card h3 { font-size: .92rem; font-weight: 700; }
|
||||||
|
.feature-card p { font-size: .8rem; color: var(--text-muted); line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── CTA BANNER ───────────────────────────────────────────── */
|
||||||
|
.cta-section {
|
||||||
|
background: linear-gradient(135deg, var(--blue) 0%, #0077cc 100%);
|
||||||
|
padding: 72px 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.cta-section h2 { font-size: 2rem; font-weight: 800; color: #fff; margin-bottom: 12px; }
|
||||||
|
.cta-section p { color: rgba(255,255,255,.75); margin-bottom: 32px; }
|
||||||
|
.btn-cta {
|
||||||
|
display: inline-block; padding: 14px 36px; border-radius: 10px;
|
||||||
|
background: #fff; color: var(--blue-dark); font-weight: 800;
|
||||||
|
font-size: 1rem; text-decoration: none; transition: transform .15s, box-shadow .15s;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.btn-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(0,0,0,.25); }
|
||||||
|
|
||||||
|
/* ── FOOTER ───────────────────────────────────────────────── */
|
||||||
|
footer {
|
||||||
|
background: var(--blue-dark); color: rgba(255,255,255,.6);
|
||||||
|
padding: 32px 40px; display: flex; justify-content: space-between;
|
||||||
|
align-items: center; flex-wrap: wrap; gap: 12px; font-size: .82rem;
|
||||||
|
}
|
||||||
|
footer .footer-logo { color: #fff; font-weight: 700; }
|
||||||
|
footer a { color: rgba(255,255,255,.6); text-decoration: none; }
|
||||||
|
footer a:hover { color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── NAV ─────────────────────────────────────────────────────── -->
|
||||||
|
<nav>
|
||||||
|
<a href="index.html" class="nav-logo">
|
||||||
|
<span class="dot-kt">.kt</span>
|
||||||
|
domain.kt
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#how">Как это работает</a>
|
||||||
|
<a href="#features">Возможности</a>
|
||||||
|
<a href="#examples">Примеры</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-actions">
|
||||||
|
<a href="login.html" class="btn-outline">Войти</a>
|
||||||
|
<a href="login.html#register" class="btn-primary-sm">Зарегистрироваться</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── HERO ────────────────────────────────────────────────────── -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-badge">
|
||||||
|
<span class="pulse"></span>
|
||||||
|
Внутренняя сеть Казахтелеком · Только для сотрудников
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Ваш корпоративный домен<br><span class="accent">yourname.kt</span></h1>
|
||||||
|
<p>Зарегистрируйте персональный или проектный домен второго уровня в зоне <strong style="color:#fff;">.kt</strong>. Автоматически на основе вашего CDN или любое свободное имя.</p>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-box">
|
||||||
|
<div class="search-row">
|
||||||
|
<input class="search-input" id="searchInput" type="text"
|
||||||
|
placeholder="введите имя домена..." autocomplete="off"
|
||||||
|
oninput="clearResult()" />
|
||||||
|
<span class="search-suffix">.kt</span>
|
||||||
|
<button class="search-btn" onclick="checkDomain()">Проверить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-hint">Например: <em>tuleev</em>, <em>hr-portal</em>, <em>devops-team</em> — домен второго уровня в .kt</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div class="search-result" id="searchResult">
|
||||||
|
<div class="result-card" id="resultCard"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-item"><div class="stat-val">1 240</div><div class="stat-label">доменов зарегистрировано</div></div>
|
||||||
|
<div class="stat-item"><div class="stat-val">3 800+</div><div class="stat-label">сотрудников в системе</div></div>
|
||||||
|
<div class="stat-item"><div class="stat-val">< 5 мин</div><div class="stat-label">среднее время регистрации</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── HOW IT WORKS ─────────────────────────────────────────────── -->
|
||||||
|
<section class="section" id="how">
|
||||||
|
<div class="section-center">
|
||||||
|
<div class="section-label">Процесс</div>
|
||||||
|
<div class="section-title">Как зарегистрировать домен</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<h3>Войдите в систему</h3>
|
||||||
|
<p>Используйте ваш CDN, корпоративный email и пароль. Первый вход — регистрация аккаунта за 2 минуты.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<h3>Получите домен по CDN</h3>
|
||||||
|
<p>Система автоматически предложит домен на основе вашего CDN. Например, CDN <code>tuleev.a</code> → домен <code>tuleev.kt</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<h3>Добавьте проектные домены</h3>
|
||||||
|
<p>Зарегистрируйте дополнительные домены для ваших проектов или команды. Проверьте доступность прямо здесь.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<h3>Управляйте из кабинета</h3>
|
||||||
|
<p>Просматривайте все домены, меняйте настройки, переименовывайте — всё в одном месте.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── DOMAIN EXAMPLES ──────────────────────────────────────────── -->
|
||||||
|
<section class="example-section" id="examples">
|
||||||
|
<div style="max-width:960px; margin:0 auto;">
|
||||||
|
<div class="section-label">Примеры</div>
|
||||||
|
<div class="section-title">Как формируется ваш домен</div>
|
||||||
|
<div class="example-grid">
|
||||||
|
<div class="example-card">
|
||||||
|
<div class="example-card__label">CDN сотрудника</div>
|
||||||
|
<div class="example-card__cdn">tuleev.a</div>
|
||||||
|
<div class="example-card__domain">tuleev<span class="kt-part">.kt</span></div>
|
||||||
|
<div class="example-card__note">Личный домен второго уровня — автоматически</div>
|
||||||
|
</div>
|
||||||
|
<div class="example-card">
|
||||||
|
<div class="example-card__label">CDN сотрудника</div>
|
||||||
|
<div class="example-card__cdn">abenova.g</div>
|
||||||
|
<div class="example-card__domain">abenova<span class="kt-part">.kt</span></div>
|
||||||
|
<div class="example-card__note">Личный домен второго уровня — автоматически</div>
|
||||||
|
</div>
|
||||||
|
<div class="example-card">
|
||||||
|
<div class="example-card__label">Проектный домен</div>
|
||||||
|
<div class="example-card__cdn">регистрирует: tuleev.a</div>
|
||||||
|
<div class="example-card__domain">devops-team<span class="kt-part">.kt</span></div>
|
||||||
|
<div class="example-card__note">Произвольный — на проверке у IT</div>
|
||||||
|
</div>
|
||||||
|
<div class="example-card">
|
||||||
|
<div class="example-card__label">Проектный домен</div>
|
||||||
|
<div class="example-card__cdn">регистрирует: bekova.m</div>
|
||||||
|
<div class="example-card__domain">hr-portal<span class="kt-part">.kt</span></div>
|
||||||
|
<div class="example-card__note">Произвольный — подтверждён</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── FEATURES ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section" id="features">
|
||||||
|
<div class="section-center">
|
||||||
|
<div class="section-label">Возможности</div>
|
||||||
|
<div class="section-title">Всё что нужно для корпоративных доменов</div>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔍</div>
|
||||||
|
<h3>Поиск доступности</h3>
|
||||||
|
<p>Мгновенная проверка — свободен ли нужный домен в зоне .kt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Авто-регистрация по CDN</h3>
|
||||||
|
<p>При первом входе система сразу предлагает ваш персональный домен.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📦</div>
|
||||||
|
<h3>Несколько доменов</h3>
|
||||||
|
<p>Личный домен + любое количество проектных. Всё в одном кабинете.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">✏️</div>
|
||||||
|
<h3>Смена домена</h3>
|
||||||
|
<p>Имя домена можно изменить, если новое имя свободно. Без потери данных.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🛡️</div>
|
||||||
|
<h3>IT-контроль</h3>
|
||||||
|
<p>Администратор видит все домены, решает конфликты однофамильцев и нестандартные запросы.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔔</div>
|
||||||
|
<h3>Уведомления</h3>
|
||||||
|
<p>Email-оповещения о статусе заявки: одобрено, на проверке, конфликт.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── CTA ──────────────────────────────────────────────────────── -->
|
||||||
|
<section class="cta-section">
|
||||||
|
<h2>Зарегистрируйте ваш домен прямо сейчас</h2>
|
||||||
|
<p>Войдите с корпоративным CDN — домен будет готов меньше чем за 5 минут.</p>
|
||||||
|
<a href="login.html" class="btn-cta">Войти и получить домен</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── FOOTER ───────────────────────────────────────────────────── -->
|
||||||
|
<footer>
|
||||||
|
<span class="footer-logo">.kt · Казахтелеком</span>
|
||||||
|
<span>Внутренняя корпоративная сеть — только для сотрудников</span>
|
||||||
|
<span><a href="admin.html">Панель администратора</a></span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mock taken domains
|
||||||
|
const takenDomains = ['it','support','admin','helpdesk','mail','vpn','corp','intranet','hr','finance'];
|
||||||
|
|
||||||
|
function checkDomain() {
|
||||||
|
const raw = document.getElementById('searchInput').value.trim().toLowerCase().replace(/[^a-z0-9-]/g,'');
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
if (!raw) { input.focus(); return; }
|
||||||
|
|
||||||
|
const isTaken = takenDomains.includes(raw);
|
||||||
|
const variations = [raw + '-team', raw + '-dev', raw + '2'];
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="result-row">
|
||||||
|
<div class="result-domain">${raw}<span>.kt</span></div>
|
||||||
|
${isTaken
|
||||||
|
? `<span class="tag-taken">Занят</span>`
|
||||||
|
: `<span class="tag-free">Свободен</span>
|
||||||
|
<a href="login.html" class="btn-claim">Зарегистрировать</a>`
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (isTaken) {
|
||||||
|
variations.forEach(v => {
|
||||||
|
html += `
|
||||||
|
<div class="result-row">
|
||||||
|
<div class="result-domain">${v}<span>.kt</span></div>
|
||||||
|
<span class="tag-free">Свободен</span>
|
||||||
|
<a href="login.html" class="btn-claim">Зарегистрировать</a>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('resultCard').innerHTML = html;
|
||||||
|
document.getElementById('searchResult').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResult() {
|
||||||
|
document.getElementById('searchResult').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchInput').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') checkDomain();
|
||||||
|
});
|
||||||
|
|
||||||
|
// smooth scroll
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||||
|
a.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelector(a.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
397
login.html
Normal file
397
login.html
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Вход — domain.kt</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--blue: #005baa;
|
||||||
|
--blue-dark: #003f7a;
|
||||||
|
--blue-light: #e8f0fb;
|
||||||
|
--green: #1ab36e;
|
||||||
|
--red: #d93636;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--bg: #f3f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
|
background: linear-gradient(145deg, #002a5c 0%, #005baa 100%);
|
||||||
|
min-height: 100vh; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
position: fixed; top: 20px; left: 28px;
|
||||||
|
color: rgba(255,255,255,.7); text-decoration: none; font-size: .85rem;
|
||||||
|
display: flex; align-items: center; gap: 6px; transition: color .15s;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: #fff; }
|
||||||
|
|
||||||
|
.auth-logo {
|
||||||
|
text-align: center; margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.auth-logo .dot-kt {
|
||||||
|
display: inline-block; background: #60a5fa; color: var(--blue-dark);
|
||||||
|
font-size: .8rem; font-weight: 800; padding: 2px 8px;
|
||||||
|
border-radius: 6px; margin-right: 6px;
|
||||||
|
}
|
||||||
|
.auth-logo span { color: #fff; font-size: 1.15rem; font-weight: 700; }
|
||||||
|
.auth-logo p { color: rgba(255,255,255,.55); font-size: .8rem; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── TABS ─────────────────────────────────────────────────── */
|
||||||
|
.auth-card {
|
||||||
|
background: var(--card); border-radius: 18px;
|
||||||
|
width: 100%; max-width: 440px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
flex: 1; padding: 16px; text-align: center;
|
||||||
|
font-size: .88rem; font-weight: 600; cursor: pointer;
|
||||||
|
color: var(--text-muted); border: none; background: none;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
border-bottom: 3px solid transparent; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||||
|
|
||||||
|
.tab-pane { display: none; padding: 32px; }
|
||||||
|
.tab-pane.active { display: block; }
|
||||||
|
|
||||||
|
/* ── FORM ─────────────────────────────────────────────────── */
|
||||||
|
.form-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.form-subtitle { font-size: .82rem; color: var(--text-muted); margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label {
|
||||||
|
display: block; font-size: .78rem; font-weight: 600;
|
||||||
|
color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.input-wrap { position: relative; }
|
||||||
|
.input-wrap .icon {
|
||||||
|
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||||
|
color: #aaa; font-size: .95rem; pointer-events: none;
|
||||||
|
}
|
||||||
|
.input-wrap input {
|
||||||
|
width: 100%; padding: 11px 12px 11px 38px;
|
||||||
|
border: 1.5px solid var(--border); border-radius: 9px;
|
||||||
|
font-size: .9rem; color: var(--text); background: var(--bg);
|
||||||
|
transition: border-color .15s, box-shadow .15s; outline: none;
|
||||||
|
}
|
||||||
|
.input-wrap input:focus {
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,91,170,.12);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.input-wrap input.error { border-color: var(--red); }
|
||||||
|
|
||||||
|
.cdn-hint {
|
||||||
|
font-size: .75rem; color: var(--text-muted); margin-top: 5px;
|
||||||
|
}
|
||||||
|
.cdn-hint code {
|
||||||
|
background: var(--bg); padding: 1px 5px; border-radius: 4px;
|
||||||
|
font-size: .78rem; color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
|
||||||
|
.forgot { text-align: right; margin-top: -8px; margin-bottom: 20px; }
|
||||||
|
.forgot a { font-size: .78rem; color: var(--blue); text-decoration: none; }
|
||||||
|
.forgot a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%; padding: 13px; border-radius: 9px;
|
||||||
|
background: var(--blue); color: #fff; font-size: .95rem;
|
||||||
|
font-weight: 700; border: none; cursor: pointer;
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.btn-submit:hover { background: var(--blue-dark); }
|
||||||
|
.btn-submit:active { transform: scale(.98); }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin: 20px 0; color: var(--text-muted); font-size: .78rem;
|
||||||
|
}
|
||||||
|
.divider::before, .divider::after {
|
||||||
|
content: ''; flex: 1; height: 1px; background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* inline error */
|
||||||
|
.err-msg {
|
||||||
|
display: none; font-size: .78rem; color: var(--red);
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.err-msg.show { display: block; }
|
||||||
|
|
||||||
|
/* success state */
|
||||||
|
.success-block {
|
||||||
|
display: none; text-align: center; padding: 16px 0;
|
||||||
|
}
|
||||||
|
.success-block .check {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%;
|
||||||
|
background: #e6f9f0; display: flex; align-items: center;
|
||||||
|
justify-content: center; margin: 0 auto 14px; font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.success-block h3 { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.success-block p { font-size: .83rem; color: var(--text-muted); }
|
||||||
|
.success-block a { color: var(--blue); text-decoration: none; font-weight: 600; }
|
||||||
|
|
||||||
|
/* info box */
|
||||||
|
.info-box {
|
||||||
|
background: var(--blue-light); border-radius: 9px;
|
||||||
|
padding: 12px 14px; font-size: .8rem; color: var(--blue);
|
||||||
|
margin-bottom: 20px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.info-box strong { display: block; margin-bottom: 2px; }
|
||||||
|
|
||||||
|
/* password toggle */
|
||||||
|
.toggle-pw {
|
||||||
|
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||||
|
cursor: pointer; color: #aaa; font-size: .85rem; user-select: none;
|
||||||
|
}
|
||||||
|
.toggle-pw:hover { color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<a href="index.html" class="back-link">← Вернуться на главную</a>
|
||||||
|
|
||||||
|
<div class="auth-logo">
|
||||||
|
<div><span class="dot-kt">.kt</span><span>domain.kt</span></div>
|
||||||
|
<p>Корпоративный регистратор доменов Казахтелеком</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="switchTab('login')">Войти</button>
|
||||||
|
<button class="tab" onclick="switchTab('register')" id="tab-register">Регистрация</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── LOGIN PANE ──────────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane active" id="pane-login">
|
||||||
|
<div class="form-title">Добро пожаловать</div>
|
||||||
|
<div class="form-subtitle">Войдите с корпоративным CDN и паролем</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>CDN (корпоративный идентификатор)</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">👤</span>
|
||||||
|
<input type="text" id="login-cdn" placeholder="например: tuleev.a" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="cdn-hint">Формат: <code>фамилия.инициал</code> — например <code>tuleev.a</code></div>
|
||||||
|
<div class="err-msg" id="err-login-cdn">Введите ваш CDN</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">🔒</span>
|
||||||
|
<input type="password" id="login-pwd" placeholder="••••••••" autocomplete="current-password" />
|
||||||
|
<span class="toggle-pw" onclick="togglePw('login-pwd', this)">показать</span>
|
||||||
|
</div>
|
||||||
|
<div class="err-msg" id="err-login-pwd">Введите пароль</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forgot"><a href="#">Забыли пароль?</a></div>
|
||||||
|
|
||||||
|
<button class="btn-submit" onclick="doLogin()">Войти в кабинет</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── REGISTER PANE ───────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane" id="pane-register">
|
||||||
|
<div class="form-title">Создать аккаунт</div>
|
||||||
|
<div class="form-subtitle">Заполните данные для первичной регистрации</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Что такое CDN?</strong>
|
||||||
|
Корпоративный идентификатор сотрудника — выдаётся отделом кадров.
|
||||||
|
Формат: <strong>фамилия.инициал</strong>, например <code>tuleev.a</code>.
|
||||||
|
Ваш будущий домен: <strong>tuleev.kt</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>CDN</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">🪪</span>
|
||||||
|
<input type="text" id="reg-cdn" placeholder="tuleev.a"
|
||||||
|
oninput="previewDomain()" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="cdn-hint" id="domain-preview" style="color:var(--blue);font-weight:600;"></div>
|
||||||
|
<div class="err-msg" id="err-reg-cdn">Введите ваш CDN в формате фамилия.инициал</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Имя</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">✏️</span>
|
||||||
|
<input type="text" id="reg-name" placeholder="Аскар" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Фамилия</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">✏️</span>
|
||||||
|
<input type="text" id="reg-surname" placeholder="Тулеев" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Корпоративный email</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">📧</span>
|
||||||
|
<input type="email" id="reg-email" placeholder="tuleev.a@telecom.kz" />
|
||||||
|
</div>
|
||||||
|
<div class="err-msg" id="err-reg-email">Введите корпоративный email (@telecom.kz)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Отдел / Подразделение</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">🏢</span>
|
||||||
|
<input type="text" id="reg-dept" placeholder="Департамент цифровых продуктов" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">🔒</span>
|
||||||
|
<input type="password" id="reg-pwd" placeholder="Минимум 8 символов" />
|
||||||
|
<span class="toggle-pw" onclick="togglePw('reg-pwd', this)">показать</span>
|
||||||
|
</div>
|
||||||
|
<div class="err-msg" id="err-reg-pwd">Пароль должен быть не менее 8 символов</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Повторите пароль</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">🔒</span>
|
||||||
|
<input type="password" id="reg-pwd2" placeholder="Повторите пароль" />
|
||||||
|
</div>
|
||||||
|
<div class="err-msg" id="err-reg-pwd2">Пароли не совпадают</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success state (hidden initially) -->
|
||||||
|
<div class="success-block" id="reg-success">
|
||||||
|
<div class="check">✅</div>
|
||||||
|
<h3>Аккаунт создан!</h3>
|
||||||
|
<p>Письмо с подтверждением отправлено на ваш корпоративный email.<br>
|
||||||
|
<a href="dashboard.html">Перейти в личный кабинет →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-submit" id="reg-btn" onclick="doRegister()">Создать аккаунт</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { api, saveSession, getToken } from './app.js';
|
||||||
|
|
||||||
|
// Already logged in? bounce to dashboard.
|
||||||
|
if (getToken()) window.location.href = 'dashboard.html';
|
||||||
|
|
||||||
|
// ── Tab switching ──────────────────────────────────────────────
|
||||||
|
window.switchTab = function(name) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab')[name === 'login' ? 0 : 1].classList.add('active');
|
||||||
|
document.getElementById('pane-' + name).classList.add('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (location.hash === '#register') window.switchTab('register');
|
||||||
|
|
||||||
|
window.previewDomain = function() {
|
||||||
|
const cdn = document.getElementById('reg-cdn').value.trim().toLowerCase();
|
||||||
|
const preview = document.getElementById('domain-preview');
|
||||||
|
if (!cdn) { preview.textContent = ''; return; }
|
||||||
|
const surname = cdn.split('.')[0];
|
||||||
|
if (surname) preview.textContent = `Ваш домен будет: ${surname}.kt`;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.togglePw = function(fieldId, btn) {
|
||||||
|
const f = document.getElementById(fieldId);
|
||||||
|
if (f.type === 'password') { f.type = 'text'; btn.textContent = 'скрыть'; }
|
||||||
|
else { f.type = 'password'; btn.textContent = 'показать'; }
|
||||||
|
};
|
||||||
|
|
||||||
|
function setErr(id, show, text) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
if (text) el.textContent = text;
|
||||||
|
if (show) el.classList.add('show'); else el.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.doLogin = async function() {
|
||||||
|
const cdn = document.getElementById('login-cdn').value.trim();
|
||||||
|
const pwd = document.getElementById('login-pwd').value;
|
||||||
|
setErr('err-login-cdn', !cdn);
|
||||||
|
setErr('err-login-pwd', !pwd);
|
||||||
|
if (!cdn || !pwd) return;
|
||||||
|
try {
|
||||||
|
const data = await api('/auth/login', { method: 'POST', body: { cdn, password: pwd } });
|
||||||
|
saveSession(data.token, { cdn: data.cdn, name: data.name, role: data.role });
|
||||||
|
window.location.href = data.role === 'admin' ? 'admin.html' : 'dashboard.html';
|
||||||
|
} catch (e) {
|
||||||
|
setErr('err-login-pwd', true, e.message || 'Неверный CDN или пароль');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.doRegister = async function() {
|
||||||
|
const cdn = document.getElementById('reg-cdn').value.trim();
|
||||||
|
const name = document.getElementById('reg-name').value.trim();
|
||||||
|
const surname = document.getElementById('reg-surname').value.trim();
|
||||||
|
const email = document.getElementById('reg-email').value.trim();
|
||||||
|
const dept = document.getElementById('reg-dept').value.trim();
|
||||||
|
const pwd = document.getElementById('reg-pwd').value;
|
||||||
|
const pwd2 = document.getElementById('reg-pwd2').value;
|
||||||
|
|
||||||
|
const cdnOk = /^[a-z0-9-]+\.[a-z]$/i.test(cdn);
|
||||||
|
const emailOk = /\S+@\S+\.\S+/.test(email);
|
||||||
|
const pwdOk = pwd.length >= 1;
|
||||||
|
const pwd2Ok = pwd === pwd2;
|
||||||
|
|
||||||
|
setErr('err-reg-cdn', !cdnOk);
|
||||||
|
setErr('err-reg-email', !emailOk);
|
||||||
|
setErr('err-reg-pwd', !pwdOk);
|
||||||
|
setErr('err-reg-pwd2', !pwd2Ok);
|
||||||
|
if (!cdnOk || !emailOk || !pwdOk || !pwd2Ok) return;
|
||||||
|
|
||||||
|
const fullName = (name + ' ' + surname).trim() || cdn;
|
||||||
|
try {
|
||||||
|
const data = await api('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { cdn, name: fullName, email, department: dept, password: pwd },
|
||||||
|
});
|
||||||
|
saveSession(data.token, { cdn: data.cdn, name: data.name, role: data.role });
|
||||||
|
document.getElementById('reg-btn').style.display = 'none';
|
||||||
|
const ok = document.getElementById('reg-success');
|
||||||
|
ok.style.display = 'block';
|
||||||
|
ok.querySelector('h3').textContent = 'Аккаунт создан!';
|
||||||
|
ok.querySelector('p').innerHTML =
|
||||||
|
`Ваш персональный домен <strong>${data.previewDomain}</strong> регистрируется в Zorro (2–10 секунд).<br>` +
|
||||||
|
`<a href="dashboard.html">Перейти в личный кабинет →</a>`;
|
||||||
|
} catch (e) {
|
||||||
|
setErr('err-reg-cdn', true, e.message || 'Не удалось зарегистрироваться');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
if (document.getElementById('pane-login').classList.contains('active')) window.doLogin();
|
||||||
|
else window.doRegister();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
136
prd.md
Normal file
136
prd.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
# PRD — domain.kt
|
||||||
|
|
||||||
|
**Тип:** MVP
|
||||||
|
**Владелец:** Аскар Тулеев
|
||||||
|
**Аудитория документа:** разработчики, системные аналитики
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Внутри Казахтелеком нет централизованного инструмента для управления корпоративными доменными именами сотрудников. Каждый проект придумывает адресацию по-своему, нет единого реестра, нет процесса согласования. Хочется простой, управляемый процесс с прозрачным статусом на каждом шаге.
|
||||||
|
|
||||||
|
Параллельная задача: обкатать Zorro как внутренний BPMN-движок на реальном, но несложном процессе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что делает продукт
|
||||||
|
|
||||||
|
Позволяет сотруднику Казахтелеком:
|
||||||
|
- получить персональный домен второго уровня в зоне `.kt` автоматически, на основе CDN
|
||||||
|
- зарегистрировать дополнительные проектные домены
|
||||||
|
- сменить имя домена, если оно свободно
|
||||||
|
- видеть статус всех своих доменов в одном месте
|
||||||
|
|
||||||
|
Позволяет IT-администратору:
|
||||||
|
- видеть полный реестр доменов `.kt`
|
||||||
|
- одобрять или отклонять проектные заявки
|
||||||
|
- разрешать конфликты (например, два сотрудника с одинаковой фамилией)
|
||||||
|
- деактивировать домен при необходимости
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пользователи
|
||||||
|
|
||||||
|
### Сотрудник
|
||||||
|
Рядовой сотрудник Казахтелеком. Работает через `index.html` → `login.html` → `dashboard.html`. Знает свой CDN (выдаётся HR). Хочет быстро получить домен и не думать о деталях.
|
||||||
|
|
||||||
|
### IT-администратор
|
||||||
|
Сотрудник IT-отдела. Работает через `admin.html`. Рассматривает нестандартные заявки, разрешает конфликты, управляет реестром.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Функциональные требования
|
||||||
|
|
||||||
|
### Поиск домена (публичная страница)
|
||||||
|
- Поле `[ввод].kt` — пользователь видит суффикс справа, вводит только имя
|
||||||
|
- Мгновенная проверка: занят / свободен / зарезервирован
|
||||||
|
- Если занят — показать похожие свободные варианты
|
||||||
|
- Кнопка "Зарегистрировать" ведёт на логин
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
- Вход: CDN + пароль + корпоративный email
|
||||||
|
- При первом входе — регистрация аккаунта
|
||||||
|
- Превью домена в реальном времени при вводе CDN: `tuleev.a` → `Ваш домен: tuleev.kt`
|
||||||
|
|
||||||
|
### Кабинет сотрудника
|
||||||
|
- Список всех доменов с карточками (статус, DNS-запись, дата)
|
||||||
|
- Регистрация нового домена: поле `[ввод].kt` + тип (персональный / проектный) + назначение + DNS
|
||||||
|
- Смена имени: ввод нового имени, проверка доступности, отправка на одобрение
|
||||||
|
- История заявок
|
||||||
|
|
||||||
|
### Панель администратора
|
||||||
|
- Реестр всех доменов с фильтрами (статус, тип, поиск по имени/CDN)
|
||||||
|
- Очередь на проверке: одобрить / отклонить с причиной / предложить альтернативу
|
||||||
|
- Раздел конфликтов: видно кто с кем конфликтует, кнопка быстрого разрешения
|
||||||
|
- Список сотрудников и их доменов
|
||||||
|
- Журнал действий администратора
|
||||||
|
- Настройки: авто-одобрение, лимит доменов, зарезервированные имена
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Нефункциональные требования
|
||||||
|
|
||||||
|
| Параметр | Ожидание |
|
||||||
|
|---|---|
|
||||||
|
| Время активации персонального домена | Секунды (авто, без участия человека) |
|
||||||
|
| Время рассмотрения проектного домена | 1–2 рабочих дня (IT-администратор) |
|
||||||
|
| Эскалация при бездействии IT | 48 часов → напоминание старшему |
|
||||||
|
| Количество доменов на сотрудника | Лимит настраивается, дефолт: без ограничений |
|
||||||
|
| Аутентификация | CDN + пароль (MVP); SSO/LDAP — следующий этап |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Логика формирования домена
|
||||||
|
|
||||||
|
```
|
||||||
|
CDN: tuleev.a
|
||||||
|
└─ берём часть до точки: "tuleev"
|
||||||
|
└─ добавляем зону: "tuleev.kt"
|
||||||
|
|
||||||
|
Если "tuleev.kt" уже занят:
|
||||||
|
└─ флаг конфликта → IT-администратор
|
||||||
|
└─ может предложить: tuleev-a.kt, tuleev2.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
Проектный домен — произвольное имя, которое сотрудник вводит сам. Требует одобрения IT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения MVP
|
||||||
|
|
||||||
|
- Без реального DNS: провизия имитируется (поле DNS-записи заполняется вручную)
|
||||||
|
- Без реального LDAP/AD: валидация CDN — по базе данных приложения
|
||||||
|
- Без SSO: простая форма логин/пароль
|
||||||
|
- Без автоматического продления доменов
|
||||||
|
- Один регион, одна корпоративная зона `.kt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
```
|
||||||
|
Как сотрудник, я хочу:
|
||||||
|
- ввести свой CDN и сразу увидеть, какой домен мне достанется
|
||||||
|
- зарегистрировать домен в один клик, без звонков в IT
|
||||||
|
- видеть статус своей заявки в реальном времени
|
||||||
|
- зарегистрировать отдельный домен для своего проекта
|
||||||
|
- сменить имя домена, если найду лучше
|
||||||
|
|
||||||
|
Как IT-администратор, я хочу:
|
||||||
|
- видеть все заявки в одном месте, а не в почте
|
||||||
|
- одобрить заявку одной кнопкой
|
||||||
|
- при конфликте фамилий — быстро назначить альтернативу обоим
|
||||||
|
- заблокировать системные имена (admin, mail, vpn...)
|
||||||
|
- посмотреть журнал: кто что делал
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что не входит в MVP
|
||||||
|
|
||||||
|
- Управление DNS-записями внутри домена (субдомены, MX, TXT)
|
||||||
|
- Публичный WHOIS-сервис
|
||||||
|
- Делегирование домена другому сотруднику
|
||||||
|
- Оплата / SLA-контракты
|
||||||
|
- Интеграция с Active Directory
|
||||||
39
server/auth.js
Normal file
39
server/auth.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SECRET_FILE = path.join(__dirname, '.jwt-secret');
|
||||||
|
|
||||||
|
function loadSecret() {
|
||||||
|
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
|
||||||
|
if (!fs.existsSync(SECRET_FILE)) {
|
||||||
|
fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET = loadSecret();
|
||||||
|
|
||||||
|
export function sign(payload) {
|
||||||
|
return jwt.sign(payload, SECRET, { expiresIn: '30d' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) return res.status(401).json({ error: 'No token' });
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, SECRET);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(req, res, next) {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
111
server/db.js
Normal file
111
server/db.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.sqlite');
|
||||||
|
|
||||||
|
export const db = new DatabaseSync(dbPath);
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cdn TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
department TEXT,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'employee',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
owner_cdn TEXT NOT NULL REFERENCES employees(cdn),
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('personal','project')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('active','pending','conflict','inactive')),
|
||||||
|
dns_record TEXT,
|
||||||
|
purpose TEXT,
|
||||||
|
zorro_instance_key TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain_id INTEGER REFERENCES domains(id),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
initiated_by TEXT REFERENCES employees(cdn),
|
||||||
|
resolved_by TEXT REFERENCES employees(cdn),
|
||||||
|
admin_comment TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
resolved_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
actor_cdn TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
details TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS process_variables (
|
||||||
|
process_instance_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'STRING',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (process_instance_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS process_claims (
|
||||||
|
task_id TEXT PRIMARY KEY,
|
||||||
|
claimed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function setVar(pid, name, value, type = 'STRING') {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO process_variables (process_instance_id, name, value, type, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(process_instance_id, name) DO UPDATE SET value=excluded.value, type=excluded.type, updated_at=datetime('now')`
|
||||||
|
).run(pid, name, value == null ? null : String(value), type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVars(pid, obj) {
|
||||||
|
for (const [name, v] of Object.entries(obj)) {
|
||||||
|
const type = typeof v === 'boolean' ? 'BOOLEAN' : 'STRING';
|
||||||
|
setVar(pid, name, v, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVars(pid) {
|
||||||
|
const rows = db.prepare('SELECT name, value, type FROM process_variables WHERE process_instance_id = ?').all(pid);
|
||||||
|
const out = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === 'BOOLEAN') out[r.name] = r.value === 'true';
|
||||||
|
else out[r.name] = r.value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryClaim(taskId) {
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT INTO process_claims (task_id) VALUES (?)').run(taskId);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audit(actorCdn, action, target, details) {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO audit_log (actor_cdn, action, target, details) VALUES (?,?,?,?)'
|
||||||
|
).run(actorCdn, action, target, details ? JSON.stringify(details) : null);
|
||||||
|
}
|
||||||
134
server/domains.js
Normal file
134
server/domains.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { db, audit } from './db.js';
|
||||||
|
import * as pdns from './pdns.js';
|
||||||
|
|
||||||
|
const RESERVED = new Set([
|
||||||
|
'admin', 'www', 'mail', 'vpn', 'ns', 'ns1', 'ns2', 'api', 'app', 'dev',
|
||||||
|
'test', 'staging', 'prod', 'root', 'kt',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||||
|
|
||||||
|
export function normalize(name) {
|
||||||
|
return (name || '').toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateName(name) {
|
||||||
|
if (!name) return 'Name required';
|
||||||
|
if (name.length < 2 || name.length > 63) return 'Length 2..63';
|
||||||
|
if (!NAME_RE.test(name)) return 'Only a-z, 0-9, hyphen; no leading/trailing hyphen';
|
||||||
|
if (RESERVED.has(name)) return 'Reserved name';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalNameFromCdn(cdn) {
|
||||||
|
return normalize(cdn.split('.')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaken(name) {
|
||||||
|
return !!db.prepare('SELECT 1 FROM domains WHERE name = ?').get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggest(name) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 2; i <= 5 && out.length < 3; i++) {
|
||||||
|
const cand = `${name}${i}`;
|
||||||
|
if (!isTaken(cand) && !validateName(cand)) out.push(`${cand}.kt`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionDns(name, dnsRecord) {
|
||||||
|
const defaultIp = process.env.DEFAULT_DNS_IP || '192.168.68.59';
|
||||||
|
const ip = dnsRecord && /^\d+\.\d+\.\d+\.\d+$/.test(dnsRecord) ? dnsRecord : defaultIp;
|
||||||
|
await pdns.createA(name, ip);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerPersonal(employee) {
|
||||||
|
const name = personalNameFromCdn(employee.cdn);
|
||||||
|
const err = validateName(name);
|
||||||
|
if (err) throw new Error(`Cannot derive personal domain from CDN: ${err}`);
|
||||||
|
if (isTaken(name)) {
|
||||||
|
const info = db
|
||||||
|
.prepare('INSERT INTO domains (name, owner_cdn, type, status) VALUES (?,?,?,?)')
|
||||||
|
.run(`${name}-${Date.now()}`, employee.cdn, 'personal', 'conflict');
|
||||||
|
audit(employee.cdn, 'domain.conflict', name, { employee: employee.cdn });
|
||||||
|
return { id: info.lastInsertRowid, name, status: 'conflict' };
|
||||||
|
}
|
||||||
|
const ip = await provisionDns(name, null);
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO domains (name, owner_cdn, type, status, dns_record) VALUES (?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(name, employee.cdn, 'personal', 'active', ip);
|
||||||
|
audit(employee.cdn, 'domain.registered.personal', name, { ip });
|
||||||
|
return { id: info.lastInsertRowid, name, fqdn: `${name}.kt`, status: 'active', ip };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerProject(employee, { name, purpose, dns }) {
|
||||||
|
name = normalize(name);
|
||||||
|
const err = validateName(name);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
if (isTaken(name)) return { error: 'taken', suggestions: suggest(name) };
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO domains (name, owner_cdn, type, status, dns_record, purpose) VALUES (?,?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(name, employee.cdn, 'project', 'pending', dns || null, purpose || null);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO requests (domain_id, type, status, initiated_by) VALUES (?,?,?,?)'
|
||||||
|
).run(info.lastInsertRowid, 'register', 'pending', employee.cdn);
|
||||||
|
audit(employee.cdn, 'domain.requested.project', name, { purpose });
|
||||||
|
return { id: info.lastInsertRowid, name, fqdn: `${name}.kt`, status: 'pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approve(domainId, admin, comment) {
|
||||||
|
const d = db.prepare('SELECT * FROM domains WHERE id = ?').get(domainId);
|
||||||
|
if (!d) throw new Error('Not found');
|
||||||
|
if (d.status !== 'pending') throw new Error(`Cannot approve in status ${d.status}`);
|
||||||
|
const ip = await provisionDns(d.name, d.dns_record);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE domains SET status='active', dns_record=?, updated_at=datetime('now') WHERE id=?"
|
||||||
|
).run(ip, domainId);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE requests SET status='approved', resolved_by=?, admin_comment=?, resolved_at=datetime('now') WHERE domain_id=? AND status='pending'"
|
||||||
|
).run(admin.cdn, comment || null, domainId);
|
||||||
|
audit(admin.cdn, 'domain.approve', d.name, { comment, ip });
|
||||||
|
return { id: domainId, status: 'active', ip };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reject(domainId, admin, comment) {
|
||||||
|
const d = db.prepare('SELECT * FROM domains WHERE id = ?').get(domainId);
|
||||||
|
if (!d) throw new Error('Not found');
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE domains SET status='inactive', updated_at=datetime('now') WHERE id=?"
|
||||||
|
).run(domainId);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE requests SET status='rejected', resolved_by=?, admin_comment=?, resolved_at=datetime('now') WHERE domain_id=? AND status='pending'"
|
||||||
|
).run(admin.cdn, comment || null, domainId);
|
||||||
|
audit(admin.cdn, 'domain.reject', d.name, { comment });
|
||||||
|
return { id: domainId, status: 'inactive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listByOwner(cdn) {
|
||||||
|
return db
|
||||||
|
.prepare("SELECT id, name, (name||'.kt') AS fqdn, type, status, dns_record, created_at FROM domains WHERE owner_cdn = ? ORDER BY created_at DESC")
|
||||||
|
.all(cdn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminList(filter = {}) {
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
if (filter.status) { where.push('d.status = ?'); params.push(filter.status); }
|
||||||
|
if (filter.type) { where.push('d.type = ?'); params.push(filter.type); }
|
||||||
|
if (filter.search) { where.push('d.name LIKE ?'); params.push(`%${filter.search}%`); }
|
||||||
|
const sql = `
|
||||||
|
SELECT d.id, d.name, (d.name||'.kt') AS fqdn, d.type, d.status, d.purpose, d.dns_record,
|
||||||
|
d.owner_cdn, e.name AS owner_name, e.email AS owner_email, d.created_at
|
||||||
|
FROM domains d LEFT JOIN employees e ON e.cdn = d.owner_cdn
|
||||||
|
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
`;
|
||||||
|
return db.prepare(sql).all(...params);
|
||||||
|
}
|
||||||
205
server/index.js
Normal file
205
server/index.js
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { db, audit, setVars, getVars } from './db.js';
|
||||||
|
import { sign, requireAuth, requireAdmin } from './auth.js';
|
||||||
|
import * as domains from './domains.js';
|
||||||
|
import * as zorro from './zorro.js';
|
||||||
|
import { startWorkers } from './workers.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const STATIC_DIR = path.resolve(__dirname, '..');
|
||||||
|
const PORT = Number(process.env.PORT || 8800);
|
||||||
|
const PROCESS_KEY = process.env.PROCESS_KEY || 'kt_domain_registration';
|
||||||
|
const PROCESS_VERSION = Number(process.env.PROCESS_VERSION || 1);
|
||||||
|
const DEFAULT_DNS_IP = process.env.DEFAULT_DNS_IP || '192.168.68.59';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
async function startRegistration(employee, { domainType, requestedName, purpose, dnsTarget }) {
|
||||||
|
const vars = {
|
||||||
|
employeeCdn: employee.cdn,
|
||||||
|
domainType,
|
||||||
|
requestedName: requestedName || '',
|
||||||
|
employeeEmail: employee.email,
|
||||||
|
purpose: purpose || '',
|
||||||
|
dnsRecordType: 'A',
|
||||||
|
dnsTarget: dnsTarget || DEFAULT_DNS_IP,
|
||||||
|
};
|
||||||
|
const pid = await zorro.startProcess(PROCESS_KEY, PROCESS_VERSION, vars);
|
||||||
|
setVars(pid, vars);
|
||||||
|
audit(employee.cdn, 'process.started', pid, { domainType, requestedName });
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
app.post('/auth/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { cdn, name, email, department, password } = req.body || {};
|
||||||
|
if (!cdn || !name || !email || !password) return res.status(400).json({ error: 'Missing fields' });
|
||||||
|
const cdnNorm = cdn.toLowerCase().trim();
|
||||||
|
if (!/^[a-z0-9-]+\.[a-z]$/.test(cdnNorm)) return res.status(400).json({ error: 'CDN format: surname.x' });
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = db
|
||||||
|
.prepare('INSERT INTO employees (cdn, name, email, department, password_hash) VALUES (?,?,?,?,?)')
|
||||||
|
.run(cdnNorm, name, email, department || null, hash);
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) return res.status(409).json({ error: 'CDN or email already used' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const employee = { id: info.lastInsertRowid, cdn: cdnNorm, name, email, role: 'employee' };
|
||||||
|
const token = sign({ cdn: cdnNorm, role: 'employee', name });
|
||||||
|
let processInstanceId = null;
|
||||||
|
try {
|
||||||
|
processInstanceId = await startRegistration(employee, { domainType: 'personal' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[auth.register] Zorro start failed:', e.message);
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
token, role: 'employee', name, cdn: cdnNorm,
|
||||||
|
previewDomain: `${domains.personalNameFromCdn(cdnNorm)}.kt`,
|
||||||
|
processInstanceId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/login', async (req, res) => {
|
||||||
|
const { cdn, password } = req.body || {};
|
||||||
|
if (!cdn || !password) return res.status(400).json({ error: 'Missing fields' });
|
||||||
|
const row = db.prepare('SELECT * FROM employees WHERE cdn = ?').get(cdn.toLowerCase().trim());
|
||||||
|
if (!row) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
const ok = await bcrypt.compare(password, row.password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
const token = sign({ cdn: row.cdn, role: row.role, name: row.name });
|
||||||
|
res.json({ token, role: row.role, name: row.name, cdn: row.cdn });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/me', requireAuth, (req, res) => {
|
||||||
|
const row = db.prepare('SELECT id, cdn, name, email, department, role FROM employees WHERE cdn = ?').get(req.user.cdn);
|
||||||
|
if (!row) return res.status(404).json({ error: 'User vanished' });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Domains ---
|
||||||
|
app.get('/domains/check', (req, res) => {
|
||||||
|
const name = domains.normalize(req.query.name);
|
||||||
|
const err = domains.validateName(name);
|
||||||
|
if (err) return res.json({ available: false, reason: err });
|
||||||
|
if (domains.isTaken(name)) return res.json({ available: false, reason: 'taken', suggestions: domains.suggest(name) });
|
||||||
|
res.json({ available: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/domains', requireAuth, (req, res) => {
|
||||||
|
res.json(domains.listByOwner(req.user.cdn));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/domains/register', requireAuth, async (req, res) => {
|
||||||
|
const employee = db.prepare('SELECT * FROM employees WHERE cdn = ?').get(req.user.cdn);
|
||||||
|
if (!employee) return res.status(401).json({ error: 'Unknown user' });
|
||||||
|
const { type, name, purpose, dns } = req.body || {};
|
||||||
|
if (type !== 'personal' && type !== 'project') {
|
||||||
|
return res.status(400).json({ error: 'type must be personal or project' });
|
||||||
|
}
|
||||||
|
if (type === 'project') {
|
||||||
|
const normalized = domains.normalize(name);
|
||||||
|
const err = domains.validateName(normalized);
|
||||||
|
if (err) return res.status(400).json({ error: err });
|
||||||
|
if (domains.isTaken(normalized)) {
|
||||||
|
return res.status(409).json({ error: 'taken', suggestions: domains.suggest(normalized) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pid = await startRegistration(employee, {
|
||||||
|
domainType: type,
|
||||||
|
requestedName: type === 'project' ? domains.normalize(name) : '',
|
||||||
|
purpose,
|
||||||
|
dnsTarget: dns,
|
||||||
|
});
|
||||||
|
res.json({ processInstanceId: pid, status: 'processing' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(502).json({ error: `Zorro: ${e.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Admin ---
|
||||||
|
app.get('/admin/domains', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
const status = req.query.status;
|
||||||
|
if (status === 'pending') {
|
||||||
|
try {
|
||||||
|
const tasks = await zorro.listUserTasks({
|
||||||
|
candidateGroup: 'kt_domain_admins',
|
||||||
|
assigned: false,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
const out = [];
|
||||||
|
for (const t of tasks) {
|
||||||
|
const v = getVars(t.processInstanceId);
|
||||||
|
if (!v.employeeCdn) continue; // foreign process instance — not ours
|
||||||
|
out.push({
|
||||||
|
userTaskId: t.id,
|
||||||
|
processInstanceId: t.processInstanceId,
|
||||||
|
name: v.finalDomainName || v.requestedName || '',
|
||||||
|
fqdn: `${v.finalDomainName || v.requestedName || ''}.kt`,
|
||||||
|
type: v.domainType || 'project',
|
||||||
|
owner_cdn: v.employeeCdn,
|
||||||
|
owner_email: v.employeeEmail,
|
||||||
|
purpose: v.purpose || '',
|
||||||
|
dns_record: v.dnsTarget,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json(out);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[admin] Zorro:', e.message);
|
||||||
|
return res.status(502).json({ error: `Zorro: ${e.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(domains.adminList(req.query));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/user-tasks/:id/approve', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await zorro.completeUserTask(req.params.id, {
|
||||||
|
adminDecision: 'APPROVE',
|
||||||
|
adminComment: req.body?.comment || '',
|
||||||
|
});
|
||||||
|
audit(req.user.cdn, 'userTask.approve', req.params.id, req.body || {});
|
||||||
|
res.json({ status: 'approved' });
|
||||||
|
} catch (e) { res.status(502).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/user-tasks/:id/reject', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await zorro.completeUserTask(req.params.id, {
|
||||||
|
adminDecision: 'REJECT',
|
||||||
|
adminComment: req.body?.comment || '',
|
||||||
|
});
|
||||||
|
audit(req.user.cdn, 'userTask.reject', req.params.id, req.body || {});
|
||||||
|
res.json({ status: 'rejected' });
|
||||||
|
} catch (e) { res.status(502).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Debug ---
|
||||||
|
app.get('/admin/process/:pid', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
res.json({ processInstanceId: req.params.pid, variables: getVars(req.params.pid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Static frontend (last, so API routes win) ---
|
||||||
|
app.use(express.static(STATIC_DIR, { extensions: ['html'] }));
|
||||||
|
app.get('/', (_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`domain.kt server on :${PORT}`);
|
||||||
|
console.log(`static from ${STATIC_DIR}`);
|
||||||
|
console.log(`Zorro at ${process.env.ZORRO_URL || 'http://10.9.0.12:9092'}`);
|
||||||
|
startWorkers();
|
||||||
|
});
|
||||||
953
server/package-lock.json
generated
Normal file
953
server/package-lock.json
generated
Normal file
@ -0,0 +1,953 @@
|
|||||||
|
{
|
||||||
|
"name": "domain-kt-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "domain-kt-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
|
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.14.0",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.3",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.12",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.14.0",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
server/package.json
Normal file
15
server/package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "domain-kt-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
server/pdns.js
Normal file
42
server/pdns.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const KEY_FILE = process.env.PDNS_KEY_FILE || path.join(os.homedir(), '.pdns-api-key');
|
||||||
|
const PDNS_API = process.env.PDNS_API || 'http://127.0.0.1:8081/api/v1/servers/localhost';
|
||||||
|
const ZONE = process.env.PDNS_ZONE || 'kt.';
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
return fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchZone(rrsets) {
|
||||||
|
const res = await fetch(`${PDNS_API}/zones/${ZONE}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'X-API-Key': key(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rrsets }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`PowerDNS ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createA(name, ip, ttl = 60) {
|
||||||
|
const fqdn = name.endsWith('.') ? name : `${name}.${ZONE}`;
|
||||||
|
await patchZone([
|
||||||
|
{
|
||||||
|
name: fqdn,
|
||||||
|
type: 'A',
|
||||||
|
ttl,
|
||||||
|
changetype: 'REPLACE',
|
||||||
|
records: [{ content: ip, disabled: false }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return fqdn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecord(name, type = 'A') {
|
||||||
|
const fqdn = name.endsWith('.') ? name : `${name}.${ZONE}`;
|
||||||
|
await patchZone([{ name: fqdn, type, changetype: 'DELETE' }]);
|
||||||
|
}
|
||||||
24
server/promote-admin.mjs
Executable file
24
server/promote-admin.mjs
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Usage: node promote-admin.mjs <cdn>
|
||||||
|
// (run from server/ directory, or set DB_PATH env var)
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.sqlite');
|
||||||
|
|
||||||
|
const cdn = process.argv[2];
|
||||||
|
if (!cdn) {
|
||||||
|
console.error('Usage: node promote-admin.mjs <cdn>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new DatabaseSync(dbPath);
|
||||||
|
const row = db.prepare('SELECT cdn, role FROM employees WHERE cdn = ?').get(cdn);
|
||||||
|
if (!row) {
|
||||||
|
console.error(`No employee with cdn=${cdn}. Register via the UI first.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE employees SET role = 'admin' WHERE cdn = ?").run(cdn);
|
||||||
|
console.log(`Promoted ${cdn} from ${row.role} → admin`);
|
||||||
6
server/start.sh
Executable file
6
server/start.sh
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/Cellar/simdjson/4.4.2/lib:/opt/homebrew/lib:/usr/local/lib:/usr/lib"
|
||||||
|
export PORT="${PORT:-8800}"
|
||||||
|
exec node index.js
|
||||||
154
server/workers.js
Normal file
154
server/workers.js
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { db, audit, setVars, getVars, tryClaim } from './db.js';
|
||||||
|
const ACTIVE_PIDS_SQL = `
|
||||||
|
SELECT DISTINCT process_instance_id AS pid FROM process_variables
|
||||||
|
WHERE updated_at > datetime('now', '-7 days')
|
||||||
|
`;
|
||||||
|
|
||||||
|
import * as zorro from './zorro.js';
|
||||||
|
import * as pdns from './pdns.js';
|
||||||
|
|
||||||
|
const POLL_MS = Number(process.env.WORKER_POLL_MS || 2000);
|
||||||
|
|
||||||
|
const JOB_TYPES = [
|
||||||
|
'employee.validateAndBuild',
|
||||||
|
'domain.checkAvailability',
|
||||||
|
'domain.autoApprove',
|
||||||
|
'pdns.createRecord',
|
||||||
|
'notification.sendActivation',
|
||||||
|
'notification.sendRejection',
|
||||||
|
'notification.sendUnavailable',
|
||||||
|
];
|
||||||
|
|
||||||
|
function personalNameFromCdn(cdn) {
|
||||||
|
return String(cdn || '').toLowerCase().trim().split('.')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
async 'employee.validateAndBuild'(vars) {
|
||||||
|
const cdn = vars.employeeCdn;
|
||||||
|
const emp = db.prepare('SELECT cdn, name, email FROM employees WHERE cdn = ?').get(cdn);
|
||||||
|
if (!emp) return { isValid: false, finalDomainName: '', employeeFullName: '' };
|
||||||
|
const name =
|
||||||
|
vars.domainType === 'personal'
|
||||||
|
? personalNameFromCdn(cdn)
|
||||||
|
: String(vars.requestedName || '').toLowerCase().trim();
|
||||||
|
return { isValid: !!name, finalDomainName: name, employeeFullName: emp.name };
|
||||||
|
},
|
||||||
|
|
||||||
|
async 'domain.checkAvailability'(vars) {
|
||||||
|
const taken = !!db.prepare('SELECT 1 FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||||
|
return { isAvailable: !taken };
|
||||||
|
},
|
||||||
|
|
||||||
|
async 'domain.autoApprove'(vars) {
|
||||||
|
const existing = db.prepare('SELECT id FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||||
|
if (existing) return { domainId: String(existing.id) };
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO domains (name, owner_cdn, type, status, zorro_instance_key) VALUES (?, ?, 'personal', 'active', ?)"
|
||||||
|
)
|
||||||
|
.run(vars.finalDomainName, vars.employeeCdn, vars.__processInstanceId);
|
||||||
|
audit(vars.employeeCdn, 'domain.autoApprove', vars.finalDomainName, {});
|
||||||
|
return { domainId: String(info.lastInsertRowid) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async 'pdns.createRecord'(vars) {
|
||||||
|
const ip = vars.dnsTarget || process.env.DEFAULT_DNS_IP || '192.168.68.59';
|
||||||
|
await pdns.createA(vars.finalDomainName, ip);
|
||||||
|
const domain = db.prepare('SELECT id FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||||
|
if (domain) {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE domains SET dns_record = ?, status = 'active', updated_at = datetime('now') WHERE id = ?"
|
||||||
|
).run(ip, domain.id);
|
||||||
|
} else {
|
||||||
|
// project-approved path: INSERT now
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO domains (name, owner_cdn, type, status, dns_record, purpose, zorro_instance_key) VALUES (?, ?, ?, 'active', ?, ?, ?)"
|
||||||
|
).run(
|
||||||
|
vars.finalDomainName,
|
||||||
|
vars.employeeCdn,
|
||||||
|
vars.domainType || 'project',
|
||||||
|
ip,
|
||||||
|
vars.purpose || null,
|
||||||
|
vars.__processInstanceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
audit(vars.employeeCdn, 'pdns.createRecord', vars.finalDomainName, { ip });
|
||||||
|
return { dnsStatus: 'ok' };
|
||||||
|
},
|
||||||
|
|
||||||
|
async 'notification.sendActivation'(vars) {
|
||||||
|
console.log(`[email] ACTIVATION → ${vars.employeeEmail} : ${vars.finalDomainName}.kt активирован для ${vars.employeeFullName}`);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
async 'notification.sendRejection'(vars) {
|
||||||
|
console.log(`[email] REJECTION → ${vars.employeeEmail} : ${vars.finalDomainName}.kt отклонён (${vars.adminComment || ''})`);
|
||||||
|
const d = db.prepare('SELECT id FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||||
|
if (d) db.prepare("UPDATE domains SET status='inactive', updated_at=datetime('now') WHERE id=?").run(d.id);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
async 'notification.sendUnavailable'(vars) {
|
||||||
|
console.log(`[email] UNAVAILABLE → ${vars.employeeEmail} : имя ${vars.finalDomainName}.kt уже занято`);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function processOne(task) {
|
||||||
|
const pid = task.processInstanceId;
|
||||||
|
const storedVars = getVars(pid);
|
||||||
|
if (!Object.keys(storedVars).length) {
|
||||||
|
// foreign process instance (we didn't start it) — skip silently so we don't crash
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vars = { ...storedVars, __processInstanceId: pid };
|
||||||
|
const handler = handlers[task.job];
|
||||||
|
if (!handler) {
|
||||||
|
console.error(`[worker] no handler for job=${task.job}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = await handler(vars);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[worker] ${task.job} ${task.id} FAILED:`, e.message);
|
||||||
|
return; // leave task uncompleted → next poll will retry
|
||||||
|
}
|
||||||
|
if (out && Object.keys(out).length) setVars(pid, out);
|
||||||
|
try {
|
||||||
|
await zorro.completeServiceTask(task.id, out || {});
|
||||||
|
console.log(`[worker] ${task.job} ${task.id} completed → ${JSON.stringify(out)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[worker] complete ${task.id} FAILED:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollOnce() {
|
||||||
|
const pids = db.prepare(ACTIVE_PIDS_SQL).all().map(r => r.pid);
|
||||||
|
for (const pid of pids) {
|
||||||
|
let tasks;
|
||||||
|
try { tasks = await zorro.serviceTasksForInstance(pid); }
|
||||||
|
catch (e) { console.error(`[worker] poll ${pid}:`, e.message); continue; }
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!handlers[task.job]) continue;
|
||||||
|
if (!tryClaim(task.id)) continue;
|
||||||
|
processOne(task).catch(e => console.error('[worker] unhandled', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
export function startWorkers() {
|
||||||
|
if (timer) return;
|
||||||
|
const loop = async () => {
|
||||||
|
try { await pollOnce(); } catch (e) { console.error('[worker] pollOnce:', e); }
|
||||||
|
timer = setTimeout(loop, POLL_MS);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
console.log(`[worker] started, poll ${POLL_MS}ms, jobs: ${JOB_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWorkers() {
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
}
|
||||||
63
server/zorro.js
Normal file
63
server/zorro.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
const BASE = process.env.ZORRO_URL || 'http://10.9.0.12:9092';
|
||||||
|
|
||||||
|
async function req(method, path, body) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`Zorro ${method} ${path} → ${res.status}: ${text}`);
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toZorroVars(obj) {
|
||||||
|
const out = [];
|
||||||
|
for (const [name, v] of Object.entries(obj)) {
|
||||||
|
if (v == null) continue;
|
||||||
|
const type = typeof v === 'boolean' ? 'BOOLEAN' : 'STRING';
|
||||||
|
out.push({ name, value: String(v), type });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startProcess(key, version, vars) {
|
||||||
|
const res = await req('POST', '/process-instances', {
|
||||||
|
processDefinitionKey: key,
|
||||||
|
processDefinitionVersion: version,
|
||||||
|
variables: toZorroVars(vars),
|
||||||
|
});
|
||||||
|
return res.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceTasksForInstance(processInstanceId) {
|
||||||
|
const qs = new URLSearchParams({ processInstanceId, pageSize: '50' });
|
||||||
|
const res = await req('GET', `/service-tasks?${qs}`);
|
||||||
|
// completed=false filter is ignored by Zorro — filter client-side
|
||||||
|
return (res.data || []).filter(t => !t.completedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeServiceTask(id, vars) {
|
||||||
|
return req('POST', `/service-tasks/${id}/complete`, { variables: toZorroVars(vars) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserTasks({ candidateGroup, assigned = false, completed = false } = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (candidateGroup) qs.set('candidateGroup', candidateGroup);
|
||||||
|
qs.set('assigned', String(assigned));
|
||||||
|
qs.set('completed', String(completed));
|
||||||
|
qs.set('pageSize', '50');
|
||||||
|
const res = await req('GET', `/user-tasks?${qs}`);
|
||||||
|
return res.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeUserTask(id, vars) {
|
||||||
|
return req('POST', `/user-tasks/${id}/complete`, { variables: toZorroVars(vars) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIncidents(processInstanceId) {
|
||||||
|
const qs = new URLSearchParams({ pageSize: '20' });
|
||||||
|
if (processInstanceId) qs.set('processInstanceId', processInstanceId);
|
||||||
|
const res = await req('GET', `/incidents?${qs}`);
|
||||||
|
return res.data || [];
|
||||||
|
}
|
||||||
86
summary.md
Normal file
86
summary.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# domain.kt — Summary
|
||||||
|
|
||||||
|
Внутренний MVP-проект Казахтелеком. Цель — обкатать создание и исполнение бизнес-процессов внутри компании с помощью BPMN-движка Zorro.
|
||||||
|
|
||||||
|
**Владелец:** Аскар Тулеев
|
||||||
|
**Статус:** MVP / в разработке
|
||||||
|
**Репозиторий движка:** `git.itte.kz/Dzhunusov.Tr/zorrobpm`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Корпоративный регистратор доменов второго уровня в зоне `.kt`. Каждый сотрудник получает домен на основе своего CDN (корпоративного идентификатора) — например, CDN `tuleev.a` → домен `tuleev.kt`. Дополнительно можно зарегистрировать произвольные проектные домены.
|
||||||
|
|
||||||
|
Реальная задача — не столько сам регистратор, сколько рабочий пример того, как веб-интерфейс, BPMN-процесс и Zorro работают вместе в корпоративной среде.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что уже есть
|
||||||
|
|
||||||
|
| Файл | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `index.html` | Публичный лендинг: поиск домена, как это работает |
|
||||||
|
| `login.html` | Вход (CDN + пароль) и первичная регистрация |
|
||||||
|
| `dashboard.html` | Кабинет сотрудника: мои домены, регистрация, смена имени |
|
||||||
|
| `admin.html` | Панель IT-администратора: реестр, очередь заявок, конфликты |
|
||||||
|
| `domain-registration.bpmn` | Два исполняемых BPMN-процесса для Zorro |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как работает регистрация домена
|
||||||
|
|
||||||
|
```
|
||||||
|
Сотрудник вводит CDN на login.html
|
||||||
|
↓
|
||||||
|
dashboard.html → форма регистрации домена
|
||||||
|
↓
|
||||||
|
POST на бэкенд → запуск процесса в Zorro
|
||||||
|
↓
|
||||||
|
Zorro исполняет domain-registration.bpmn:
|
||||||
|
- валидирует CDN
|
||||||
|
- формирует имя домена
|
||||||
|
- проверяет доступность
|
||||||
|
- персональный домен → авто-активация
|
||||||
|
- проектный домен → User Task для IT-администратора (admin.html)
|
||||||
|
- при конфликте → отдельный User Task разрешения
|
||||||
|
↓
|
||||||
|
DNS-провизия → email-уведомление → домен активен
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Домены и логика формирования
|
||||||
|
|
||||||
|
| CDN | Тип | Итоговый домен |
|
||||||
|
|---|---|---|
|
||||||
|
| `tuleev.a` | Персональный | `tuleev.kt` |
|
||||||
|
| `abenova.g` | Персональный | `abenova.kt` |
|
||||||
|
| `tuleev.a` (вручную) | Проектный | `devops-team.kt` |
|
||||||
|
|
||||||
|
Один сотрудник может иметь несколько доменов (личный + проектные).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
- Бэкенд-стек (Java/Spring, Node.js, Go — не определено)
|
||||||
|
- DNS-сервер (BIND9, PowerDNS — не определено)
|
||||||
|
- База данных реестра (PostgreSQL, другое — не определено)
|
||||||
|
- Детали деплоя Zorro на Docker
|
||||||
|
- Детали API Zorro для деплоя BPMN и запуска процессов
|
||||||
|
- Аутентификация: сейчас CDN + пароль, в будущем — возможно SSO/LDAP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что дальше
|
||||||
|
|
||||||
|
1. Развернуть Zorro локально (`zorrobpm`)
|
||||||
|
2. Загрузить `domain-registration.bpmn` в Zorro
|
||||||
|
3. Реализовать job workers для каждого service task
|
||||||
|
4. Подключить фронт к бэкенду
|
||||||
|
5. Задеплоить в Docker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Подробности — в `prd.md`, `trd.md`, `zorro.md`.*
|
||||||
321
trd.md
Normal file
321
trd.md
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
# TRD — domain.kt
|
||||||
|
|
||||||
|
**Тип:** MVP
|
||||||
|
**Владелец:** Аскар Тулеев
|
||||||
|
**Аудитория документа:** разработчики, системные аналитики
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обзор архитектуры
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Браузер │
|
||||||
|
│ index.html │ login.html │ dashboard.html │ admin.html │
|
||||||
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
|
│ HTTP / REST
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend API │
|
||||||
|
│ POST /domains/check POST /domains/register │
|
||||||
|
│ POST /auth/login GET /domains │
|
||||||
|
│ POST /auth/register PATCH /domains/:id │
|
||||||
|
│ GET /admin/domains POST /admin/domains/:id/approve │
|
||||||
|
│ GET /admin/conflicts POST /admin/domains/:id/reject │
|
||||||
|
└──────────────┬───────────────────────────┬───────────────────┘
|
||||||
|
│ REST (start process, │ SQL
|
||||||
|
│ complete user task) ▼
|
||||||
|
▼ ┌────────────────┐
|
||||||
|
┌──────────────────────┐ │ База данных │
|
||||||
|
│ Zorro │ │ (PostgreSQL) │
|
||||||
|
│ (BPMN-движок) │ │ │
|
||||||
|
│ domain-registration │ │ employees │
|
||||||
|
│ .bpmn │◄────────►│ domains │
|
||||||
|
│ │ job │ requests │
|
||||||
|
└──────────────┬───────┘ workers │ audit_log │
|
||||||
|
│ └────────────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Job Workers │
|
||||||
|
│ (отдельный сервис │
|
||||||
|
│ или встроены в │
|
||||||
|
│ Backend API) │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
### Фронтенд
|
||||||
|
|
||||||
|
Статические HTML-файлы, Vanilla JS. Никаких фреймворков в MVP.
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `index.html` | Публичный лендинг, поиск домена |
|
||||||
|
| `login.html` | Вход и регистрация сотрудника |
|
||||||
|
| `dashboard.html` | Кабинет сотрудника, SPA-роутинг через `showPage()` |
|
||||||
|
| `admin.html` | Панель IT-администратора |
|
||||||
|
|
||||||
|
Все страницы используют общий CSS-дизайн: CSS-переменные (`--blue`, `--bg`, `--border` и т.д.), шрифт Inter/system-ui.
|
||||||
|
|
||||||
|
### Backend API
|
||||||
|
|
||||||
|
Стек не определён. Варианты: Java/Spring Boot, Node.js/Express, Go/Gin.
|
||||||
|
|
||||||
|
Обязанности:
|
||||||
|
- Аутентификация (CDN + пароль, хэш bcrypt)
|
||||||
|
- CRUD операции с доменами
|
||||||
|
- Запуск процессов в Zorro через REST API
|
||||||
|
- Завершение User Task в Zorro при действии администратора
|
||||||
|
- Подписка на job workers Zorro (polling loop)
|
||||||
|
|
||||||
|
### Zorro (BPMN-движок)
|
||||||
|
|
||||||
|
Внутренний движок на базе `zorrobpm`. Исполняет `domain-registration.bpmn`.
|
||||||
|
|
||||||
|
Подробнее — в `zorro.md`.
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
Предполагается PostgreSQL. Схема:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Сотрудники
|
||||||
|
CREATE TABLE employees (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cdn VARCHAR(64) UNIQUE NOT NULL, -- 'tuleev.a'
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
email VARCHAR(256) UNIQUE NOT NULL,
|
||||||
|
department VARCHAR(128),
|
||||||
|
password_hash VARCHAR(256) NOT NULL,
|
||||||
|
role VARCHAR(16) DEFAULT 'employee', -- 'employee' | 'admin'
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Реестр доменов
|
||||||
|
CREATE TABLE domains (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(128) UNIQUE NOT NULL, -- 'tuleev' (без .kt)
|
||||||
|
fqdn VARCHAR(256) GENERATED ALWAYS AS (name || '.kt') STORED,
|
||||||
|
owner_cdn VARCHAR(64) REFERENCES employees(cdn),
|
||||||
|
type VARCHAR(16) NOT NULL, -- 'personal' | 'project'
|
||||||
|
status VARCHAR(16) NOT NULL, -- 'active' | 'pending' | 'conflict' | 'inactive'
|
||||||
|
dns_record VARCHAR(512),
|
||||||
|
purpose TEXT,
|
||||||
|
zorro_instance_key BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- История заявок
|
||||||
|
CREATE TABLE requests (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
domain_id INT REFERENCES domains(id),
|
||||||
|
type VARCHAR(32) NOT NULL, -- 'register' | 'change' | 'deactivate'
|
||||||
|
status VARCHAR(16) NOT NULL, -- 'pending' | 'approved' | 'rejected'
|
||||||
|
initiated_by VARCHAR(64) REFERENCES employees(cdn),
|
||||||
|
resolved_by VARCHAR(64) REFERENCES employees(cdn),
|
||||||
|
admin_comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Журнал действий администратора
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
actor_cdn VARCHAR(64) REFERENCES employees(cdn),
|
||||||
|
action VARCHAR(128) NOT NULL,
|
||||||
|
target VARCHAR(256),
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-контракты
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/login
|
||||||
|
Body: { "cdn": "tuleev.a", "password": "..." }
|
||||||
|
Response: { "token": "jwt...", "role": "employee" }
|
||||||
|
|
||||||
|
POST /auth/register
|
||||||
|
Body: { "cdn": "tuleev.a", "name": "Аскар Тулеев", "email": "...", "department": "...", "password": "..." }
|
||||||
|
Response: { "token": "jwt...", "previewDomain": "tuleev.kt" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Домены
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /domains/check?name=devops-team
|
||||||
|
Response: { "available": true }
|
||||||
|
| { "available": false, "suggestions": ["devops-team2.kt", "devops.kt"] }
|
||||||
|
|
||||||
|
GET /domains?cdn=tuleev.a
|
||||||
|
Response: [ { "id": 1, "fqdn": "tuleev.kt", "type": "personal", "status": "active", ... } ]
|
||||||
|
|
||||||
|
POST /domains/register
|
||||||
|
Body: { "name": "devops-team", "type": "project", "purpose": "...", "dns": "10.0.0.1" }
|
||||||
|
Response: { "domainId": 42, "status": "pending", "zorroInstanceKey": 2251799813685277 }
|
||||||
|
|
||||||
|
PATCH /domains/:id/rename
|
||||||
|
Body: { "newName": "dev-team" }
|
||||||
|
Response: { "status": "pending" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Администратор
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/domains?status=pending&type=project&search=dev
|
||||||
|
Response: [ { ...domain, ownerName, ownerCdn } ]
|
||||||
|
|
||||||
|
POST /admin/domains/:id/approve
|
||||||
|
Body: { "comment": "" }
|
||||||
|
|
||||||
|
POST /admin/domains/:id/reject
|
||||||
|
Body: { "comment": "Имя зарезервировано" }
|
||||||
|
|
||||||
|
POST /admin/domains/:id/reassign
|
||||||
|
Body: { "suggestedAlternative": "tuleev-d.kt" }
|
||||||
|
|
||||||
|
GET /admin/conflicts
|
||||||
|
Response: [ { "domain": "tuleev.kt", "claimants": [ {...employee1}, {...employee2} ] } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контракты Job Workers
|
||||||
|
|
||||||
|
Каждый service task в BPMN исполняется job worker'ом. Worker подписывается на тип задачи, берёт job, выполняет логику, завершает job с переменными или ошибкой.
|
||||||
|
|
||||||
|
| Job Type | Входные переменные | Выходные переменные | Логика |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `employee.validateAndBuild` | `cdn`, `domainType`, `requestedName` | `isValid`, `finalDomainName`, `employeeFullName` | Проверить CDN в БД; personal → surname из CDN, project → requestedName |
|
||||||
|
| `domain.checkAvailability` | `domainName` | `isAvailable` | Проверить таблицу `domains` |
|
||||||
|
| `domain.autoApprove` | `domainName`, `cdn` | `domainId` | Записать домен в БД, статус `active` |
|
||||||
|
| `pdns.createRecord` | `domainName`, `recordType`, `target` | `dnsStatus` | PATCH на PowerDNS API (`127.0.0.1:8081`): создать A/CNAME запись `domainName.kt.` в зоне `kt.` |
|
||||||
|
| `notification.sendActivation` | `email`, `fullName`, `domainName` | — | Email: домен активирован |
|
||||||
|
| `notification.sendRejection` | `email`, `domainName`, `reason` | — | Email: заявка отклонена |
|
||||||
|
| `notification.sendUnavailable` | `email`, `domainName` | — | Email: имя занято |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PowerDNS — интеграция
|
||||||
|
|
||||||
|
DNS-сервер: **PowerDNS Authoritative** на Mac Studio (`192.168.68.59`).
|
||||||
|
|
||||||
|
| Компонент | Адрес | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| REST API | `http://127.0.0.1:8081/api/v1/servers/localhost` | управление записями (только с localhost) |
|
||||||
|
| Auth DNS | `127.0.0.1:5300` | авторитативные ответы по зоне `.kt` |
|
||||||
|
| Recursor | `0.0.0.0:53` | резолвер для всего LAN |
|
||||||
|
|
||||||
|
API-ключ хранится в `~/.pdns-api-key` на Mac Studio.
|
||||||
|
|
||||||
|
Job worker `pdns.createRecord` выполняет PATCH-запрос:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://127.0.0.1:8081/api/v1/servers/localhost/zones/kt. \
|
||||||
|
-H "X-API-Key: <key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"rrsets": [{
|
||||||
|
"name": "tuleev.kt.",
|
||||||
|
"type": "A",
|
||||||
|
"ttl": 60,
|
||||||
|
"changetype": "REPLACE",
|
||||||
|
"records": [{"content": "10.0.0.50", "disabled": false}]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно: имя домена всегда в FQDN-формате с точкой в конце (`tuleev.kt.`).
|
||||||
|
|
||||||
|
Поскольку API доступен только с localhost Mac Studio, job worker должен запускаться на той же машине (или через SSH-туннель).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Интеграция фронтенда с Zorro
|
||||||
|
|
||||||
|
Фронт не общается с Zorro напрямую. Схема:
|
||||||
|
|
||||||
|
```
|
||||||
|
dashboard.html
|
||||||
|
│ POST /domains/register
|
||||||
|
▼
|
||||||
|
Backend API
|
||||||
|
│ POST http://10.9.0.12:8080/api/processes/kt_domain_registration/start
|
||||||
|
│ Body: { variables: { cdn: "tuleev.a", domainType: "project", ... } }
|
||||||
|
▼
|
||||||
|
Zorro (http://10.9.0.12:8080)
|
||||||
|
│ instanceKey: 2251799813685277
|
||||||
|
▼
|
||||||
|
Backend API сохраняет instanceKey в БД → polling статуса через /domains/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Когда администратор нажимает "Одобрить" в `admin.html`:
|
||||||
|
|
||||||
|
```
|
||||||
|
admin.html
|
||||||
|
│ POST /admin/domains/:id/approve
|
||||||
|
▼
|
||||||
|
Backend API
|
||||||
|
│ POST /api/zorro/jobs/complete (или аналогичный endpoint Zorro)
|
||||||
|
│ Body: { jobKey: ..., variables: { adminDecision: "APPROVE" } }
|
||||||
|
▼
|
||||||
|
Zorro продолжает процесс
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker (набросок)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: domain_kt
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: secret
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# Zorro уже запущен на http://10.9.0.12:8080/
|
||||||
|
# Для локальной разработки можно поднять из git.itte.kz/Dzhunusov.Tr/zorrobpm
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: domain-kt-api:latest
|
||||||
|
environment:
|
||||||
|
DB_URL: postgres://app:secret@db:5432/domain_kt
|
||||||
|
ZORRO_URL: http://10.9.0.12:8080
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- zorro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
Фронт раздаётся как статика (nginx или встроен в backend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые технические вопросы
|
||||||
|
|
||||||
|
1. **Бэкенд-стек** — Java/Spring Boot, Node.js или Go?
|
||||||
|
2. **Zorro API** — точные endpoint'ы для старта процесса, завершения User Task, опроса статуса
|
||||||
|
3. **DNS** — в MVP DNS не реальный; нужно определить, будет ли BIND9/PowerDNS на следующем этапе
|
||||||
|
4. **Email** — SMTP-сервер для уведомлений (корпоративный или заглушка)
|
||||||
|
5. **JWT или сессии** — механизм авторизации для API
|
||||||
|
6. **CDN-справочник** — откуда берётся список валидных CDN (отдельная таблица, Excel-импорт, AD?)
|
||||||
|
7. **Лимит доменов** — дефолт "без ограничений" в MVP, но архитектурно нужно поле в настройках
|
||||||
|
8. **Зарезервированные имена** — список (admin, mail, vpn, www...) хранится в БД или в конфиге
|
||||||
598
zorro.md
Normal file
598
zorro.md
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
# ZorroBPM Development Guide
|
||||||
|
|
||||||
|
Единый справочник для разработки процессов на ZorroBPM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Окружения](#окружения)
|
||||||
|
- [Локальный запуск](#локальный-запуск)
|
||||||
|
- [Docker Compose — полный стек](#docker-compose--полный-стек)
|
||||||
|
- [Docker Compose — только фронт (dev)](#docker-compose--только-фронт-dev)
|
||||||
|
- [Docker Compose — фронт + воркеры](#docker-compose--фронт--воркеры)
|
||||||
|
- [API Reference](#api-reference)
|
||||||
|
- [RabbitMQ и внешние воркеры](#rabbitmq-и-внешние-воркеры)
|
||||||
|
- [Разработка процессов](#разработка-процессов)
|
||||||
|
- [Тестирование](#тестирование)
|
||||||
|
- [Примеры](#примеры)
|
||||||
|
- [Известные ограничения](#известные-ограничения)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────────┐ ┌────────────┐
|
||||||
|
│ UI │────▶│ REST API │────▶│ PostgreSQL │
|
||||||
|
│ (nginx) │ │ (Spring Boot)│ │ │
|
||||||
|
└──────────┘ └──────┬───────┘ └────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌────────────────┐
|
||||||
|
│ RabbitMQ │◀───▶│ External │
|
||||||
|
│ │ │ Workers (опц.) │
|
||||||
|
└──────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Компонент | Образ | Описание |
|
||||||
|
|-----------|-------|----------|
|
||||||
|
| **api** | `zorrodev/zorrobpm-rest` | BPM движок. Управляет определениями, инстансами, задачами, инцидентами |
|
||||||
|
| **ui** | `zorrodev/zorrobpm-ui` | Nginx SPA — мониторинг процессов, задач, деплоев |
|
||||||
|
| **db** | `postgres:18` | Хранит определения, инстансы, переменные, инциденты |
|
||||||
|
| **rabbitmq** | `rabbitmq:4.0.7-management` | Очереди для serviceTask и событий жизненного цикла |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Окружения
|
||||||
|
|
||||||
|
| Окружение | URL | Примечание |
|
||||||
|
|-----------|-----|------------|
|
||||||
|
| **LOCAL** | http://localhost:9092 | Docker Compose |
|
||||||
|
| **PROD** | https://api.bpms.telecom.kz | Через VPN |
|
||||||
|
| **DEV Studio** | https://studio.zorrobpm.dev | Визуальный редактор |
|
||||||
|
| **DEV Station** | https://station.zorrobpm.dev | Мониторинг |
|
||||||
|
| **DEMO (m11s)** | https://api.m11s.zorrobpm.dev | Микросервисная демо-среда |
|
||||||
|
|
||||||
|
Swagger UI:
|
||||||
|
- Local: http://localhost:9092/swagger-ui/index.html
|
||||||
|
- Demo: https://api.m11s.zorrobpm.dev/swagger-ui/index.html
|
||||||
|
|
||||||
|
Аутентификация: не требуется (dev-окружение).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск полного стека
|
||||||
|
docker compose -f docker-compose.yaml --env-file .env up -d
|
||||||
|
|
||||||
|
# Логи API
|
||||||
|
docker compose logs api -f
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Остановка + удаление данных
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose — полный стек
|
||||||
|
|
||||||
|
Полный стек для самостоятельного запуска движка. Используй только если нужен свой локальный инстанс BPM.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yaml — полный стек ZorroBPM
|
||||||
|
services:
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: zorrodev/zorrobpm-rest
|
||||||
|
environment:
|
||||||
|
- DB_URL=jdbc:postgresql://db/${DB_NAME}
|
||||||
|
- DB_USERNAME=${DB_USER}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
- APP_FILES_DIR=/files
|
||||||
|
- RABBITMQ_HOST=rabbitmq
|
||||||
|
- RABBITMQ_USERNAME=${RABBITMQ_USER}
|
||||||
|
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
|
||||||
|
- RABBITMQ_PORT=5672
|
||||||
|
volumes:
|
||||||
|
- files:/files
|
||||||
|
ports:
|
||||||
|
- 9092:80
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:18
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db:/var/lib/postgresql/18/docker
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:5435:5432"
|
||||||
|
|
||||||
|
ui:
|
||||||
|
image: zorrodev/zorrobpm-ui
|
||||||
|
ports:
|
||||||
|
- 9091:80
|
||||||
|
environment:
|
||||||
|
- ZORROBPM_API_URL=http://zorrobpm.local
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:4.0.7-management
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||||
|
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- rabbitmq-data:/var/lib/rabbitmq
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rabbitmq-data:
|
||||||
|
db:
|
||||||
|
files:
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env`:
|
||||||
|
```env
|
||||||
|
DB_NAME=zorrobpm
|
||||||
|
DB_USER=zorrobpm
|
||||||
|
DB_PASSWORD=zorrobpm
|
||||||
|
RABBITMQ_USER=guest
|
||||||
|
RABBITMQ_PASSWORD=guest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose — только фронт (dev)
|
||||||
|
|
||||||
|
**Для разработки процессов без локального движка.** Подключается к удалённому API (demo, dev или prod). Не нужны PostgreSQL, RabbitMQ, API — только UI.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.front.yaml — фронт без движка
|
||||||
|
services:
|
||||||
|
|
||||||
|
ui:
|
||||||
|
image: zorrodev/zorrobpm-ui
|
||||||
|
ports:
|
||||||
|
- 9091:80
|
||||||
|
environment:
|
||||||
|
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.front`:
|
||||||
|
```env
|
||||||
|
# Указать один из:
|
||||||
|
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev # demo
|
||||||
|
# ZORROBPM_API_URL=https://api.bpms.telecom.kz # prod (VPN)
|
||||||
|
# ZORROBPM_API_URL=http://localhost:9092 # local
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.front.yaml --env-file .env.front up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose — фронт + воркеры
|
||||||
|
|
||||||
|
**Когда процесс содержит serviceTask с внешними воркерами.** Фронт для мониторинга + RabbitMQ для получения задач + твой воркер.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.workers.yaml — фронт + RabbitMQ воркеры
|
||||||
|
services:
|
||||||
|
|
||||||
|
ui:
|
||||||
|
image: zorrodev/zorrobpm-ui
|
||||||
|
ports:
|
||||||
|
- 9091:80
|
||||||
|
environment:
|
||||||
|
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||||
|
|
||||||
|
# Пример Java-воркера
|
||||||
|
worker:
|
||||||
|
build: ./worker
|
||||||
|
environment:
|
||||||
|
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||||
|
- RABBITMQ_HOST=${RABBITMQ_HOST}
|
||||||
|
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
|
||||||
|
- RABBITMQ_USERNAME=${RABBITMQ_USER}
|
||||||
|
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
|
||||||
|
depends_on:
|
||||||
|
- ui
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.workers`:
|
||||||
|
```env
|
||||||
|
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev
|
||||||
|
|
||||||
|
# RabbitMQ — указать хост удалённого брокера, если он открыт,
|
||||||
|
# или поднять локально (добавить rabbitmq сервис)
|
||||||
|
RABBITMQ_HOST=rabbitmq.m11s.zorrobpm.dev
|
||||||
|
RABBITMQ_USER=guest
|
||||||
|
RABBITMQ_PASSWORD=guest
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Примечание:** если удалённый RabbitMQ недоступен извне, добавь сервис `rabbitmq` из полного стека и настрой API на использование этого брокера.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
Base URL: `http://localhost:9092` (local) или `https://api.m11s.zorrobpm.dev` (demo)
|
||||||
|
|
||||||
|
### Process Definitions
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `POST` | `/process-definitions` | Деплой BPMN |
|
||||||
|
| `GET` | `/process-definitions` | Список (с пагинацией) |
|
||||||
|
| `GET` | `/process-definitions/{id}` | По ID |
|
||||||
|
| `GET` | `/process-definitions/{id}/xml` | BPMN XML |
|
||||||
|
|
||||||
|
**Деплой:**
|
||||||
|
```http
|
||||||
|
POST /process-definitions
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "bpmn": "<bpmn:definitions>...</bpmn:definitions>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ — `ProcessDefinition`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"key": "string",
|
||||||
|
"version": 1,
|
||||||
|
"name": "string",
|
||||||
|
"sha256": "string",
|
||||||
|
"createdAt": "date-time",
|
||||||
|
"startFormKey": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Query params: `pageIndex`, `pageSize`, `processDefinitionKey`, `processDefinitionVersion`, `latestVersionOnly`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Process Instances
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `POST` | `/process-instances` | Запуск инстанса |
|
||||||
|
| `GET` | `/process-instances` | Список (с пагинацией) |
|
||||||
|
|
||||||
|
**Запуск:**
|
||||||
|
```http
|
||||||
|
POST /process-instances
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"processDefinitionId": "uuid",
|
||||||
|
"processDefinitionKey": "string",
|
||||||
|
"processDefinitionVersion": 1,
|
||||||
|
"variables": [
|
||||||
|
{ "name": "var1", "value": "val1", "type": "STRING" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Можно указать `processDefinitionId` ИЛИ `processDefinitionKey` + `processDefinitionVersion`.
|
||||||
|
|
||||||
|
Ответ: `{ "id": "uuid" }`
|
||||||
|
|
||||||
|
Query params: `id`, `pageIndex`, `pageSize`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`
|
||||||
|
|
||||||
|
Объект `ProcessInstance`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"parentActivityId": "uuid",
|
||||||
|
"processDefinitionId": "uuid",
|
||||||
|
"startedAt": "date-time",
|
||||||
|
"completedAt": "date-time"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Service Tasks
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/service-tasks` | Список сервисных задач |
|
||||||
|
| `POST` | `/service-tasks/{id}/complete` | Завершить задачу |
|
||||||
|
|
||||||
|
Query params: `id`, `jobType`, `completed`, `pageIndex`, `pageSize`, `processInstanceId`
|
||||||
|
|
||||||
|
Объект `ServiceTask`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"code": "string",
|
||||||
|
"name": "string",
|
||||||
|
"processInstanceId": "uuid",
|
||||||
|
"processDefinitionId": "uuid",
|
||||||
|
"job": "string",
|
||||||
|
"createdAt": "date-time",
|
||||||
|
"completedAt": "date-time"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Завершение:**
|
||||||
|
```http
|
||||||
|
POST /service-tasks/{id}/complete
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"variables": [
|
||||||
|
{ "name": "result", "value": "done", "type": "STRING" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Tasks
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/user-tasks` | Список пользовательских задач |
|
||||||
|
| `POST` | `/user-tasks/{id}/complete` | Завершить задачу |
|
||||||
|
|
||||||
|
Query params: `id`, `assignee`, `candidateGroup`, `candidateUser`, `completed`, `assigned`, `pageIndex`, `pageSize`, `processInstanceId`
|
||||||
|
|
||||||
|
Объект `UserTask`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"code": "string",
|
||||||
|
"name": "string",
|
||||||
|
"processInstanceId": "uuid",
|
||||||
|
"processDefinitionId": "uuid",
|
||||||
|
"formKey": "string",
|
||||||
|
"createdAt": "date-time",
|
||||||
|
"completedAt": "date-time"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Завершение:**
|
||||||
|
```http
|
||||||
|
POST /user-tasks/{id}/complete
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"variables": [
|
||||||
|
{ "name": "action", "value": "approve", "type": "STRING" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Incidents
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/incidents` | Список инцидентов |
|
||||||
|
| `POST` | `/incidents/{id}/resolve` | Разрешить инцидент |
|
||||||
|
|
||||||
|
Query params: `id`, `processInstanceId`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`, `bpmnElementId`, `pageIndex`, `pageSize`
|
||||||
|
|
||||||
|
Объект `Incident`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"activityId": "uuid",
|
||||||
|
"message": "string",
|
||||||
|
"createdAt": "date-time",
|
||||||
|
"completedAt": "date-time"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Разрешение:**
|
||||||
|
```http
|
||||||
|
POST /incidents/{id}/resolve
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "fix", "value": "retried", "type": "STRING" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Типы переменных
|
||||||
|
|
||||||
|
`STRING`, `UUID`, `LONG`, `BOOLEAN`
|
||||||
|
|
||||||
|
### Формат пагинации
|
||||||
|
|
||||||
|
Все list-эндпоинты возвращают:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pageIndex": 0,
|
||||||
|
"pageSize": 20,
|
||||||
|
"totalElements": 100,
|
||||||
|
"data": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RabbitMQ и внешние воркеры
|
||||||
|
|
||||||
|
### Очереди
|
||||||
|
|
||||||
|
| Очередь | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `zorrobpm.engine.definition-events` | Жизненный цикл определений (deployed, updated) |
|
||||||
|
| `zorrobpm.engine.runtime-events` | Жизненный цикл инстансов (created, completed) и задач |
|
||||||
|
| `zorrobpm.jobs.<job_type>` | Очередь для каждого типа serviceTask |
|
||||||
|
|
||||||
|
### Как это работает
|
||||||
|
|
||||||
|
Когда BPMN `serviceTask` с атрибутом `job="srv_child_work"` достигается движком:
|
||||||
|
1. Движок публикует сообщение в очередь `zorrobpm.jobs.srv_child_work`
|
||||||
|
2. Внешний воркер читает из этой очереди
|
||||||
|
3. Воркер выполняет работу
|
||||||
|
4. Воркер вызывает `POST /service-tasks/{id}/complete`
|
||||||
|
|
||||||
|
### Пример воркера (Java + Spring AMQP)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RabbitListener(queues = "zorrobpm.jobs.srv_child_work")
|
||||||
|
public void handleChildWork(ServiceTaskMessage msg) {
|
||||||
|
// выполнить работу...
|
||||||
|
api.completeServiceTask(msg.getTaskId());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Инспекция очередей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec zorrobpm-rabbitmq-1 rabbitmqctl list_queues name messages consumers
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Разработка процессов
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
|
|
||||||
|
1. **Нарисуй BPMN** — в Studio (https://studio.zorrobpm.dev) или любом BPMN-редакторе
|
||||||
|
2. **Деплой** — `POST /process-definitions` с BPMN XML
|
||||||
|
3. **Запусти** — `POST /process-instances` с ключом или ID определения
|
||||||
|
4. **Обрабатывай задачи**:
|
||||||
|
- `serviceTask` — воркер через RabbitMQ или ручной `POST /service-tasks/{id}/complete`
|
||||||
|
- `userTask` — UI или ручной `POST /user-tasks/{id}/complete`
|
||||||
|
|
||||||
|
### Выбор docker-compose
|
||||||
|
|
||||||
|
```
|
||||||
|
Нужен ли локальный BPM движок?
|
||||||
|
├── Да → docker-compose.yaml (полный стек)
|
||||||
|
└── Нет, использую удалённый API
|
||||||
|
├── Есть serviceTask с воркерами? → docker-compose.workers.yaml
|
||||||
|
└── Только UI/мониторинг? → docker-compose.front.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Структура проекта (рекомендуемая)
|
||||||
|
|
||||||
|
```
|
||||||
|
my-process/
|
||||||
|
├── docker-compose.yaml # выбери нужный вариант
|
||||||
|
├── .env
|
||||||
|
├── bpmn/
|
||||||
|
│ ├── myProcess.bpmn
|
||||||
|
│ └── childProcess.bpmn
|
||||||
|
├── worker/ # если есть serviceTask
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── src/
|
||||||
|
└── tests/
|
||||||
|
└── e2e/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Dev-контейнер
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.dev.yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: maven:3.9-eclipse-temurin-17
|
||||||
|
container_name: examples-dev
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- maven-cache:/root/.m2
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "5005:5005" # remote debug
|
||||||
|
command: sleep infinity
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
maven-cache:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Режимы тестирования
|
||||||
|
|
||||||
|
| Режим | Команда | Зависимости | Описание |
|
||||||
|
|-------|---------|-------------|----------|
|
||||||
|
| **embedded** | `./test.sh` | Только dev контейнер | Unit тесты, встроенный движок + H2 |
|
||||||
|
| **local** | `./test.sh local` | Dev + ZorroBPM docker | E2E против `localhost:9092` |
|
||||||
|
| **remote** | `./test.sh remote` | Dev + интернет | E2E против `api.m11s.zorrobpm.dev` |
|
||||||
|
| **custom** | `./test.sh http://my-server:8080` | Dev + сервер | E2E против произвольного URL |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
./test.sh # embedded
|
||||||
|
./test.sh local # E2E local
|
||||||
|
./test.sh remote # E2E remote
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
### call-activity — вызов дочернего процесса
|
||||||
|
|
||||||
|
**Родительский процесс** (`parentProcess.bpmn`):
|
||||||
|
```
|
||||||
|
[Start] → [CallActivity: childProcess] → [End]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Дочерний процесс** (`childProcess.bpmn`):
|
||||||
|
```
|
||||||
|
[Start] → [ServiceTask: srv_child_work] → [End]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поток выполнения:**
|
||||||
|
1. Деплой обоих BPMN
|
||||||
|
2. Запуск родительского — он входит в `callActivity`, создаёт дочерний инстанс
|
||||||
|
3. Дочерний работает в **отдельном processInstance**
|
||||||
|
4. Завершение serviceTask в дочернем → callActivity возобновляется → родитель завершается
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
```
|
||||||
|
src/main/resources/examples/call-activity/
|
||||||
|
parentProcess.bpmn
|
||||||
|
childProcess.bpmn
|
||||||
|
|
||||||
|
src/test/java/.../examples/callactivity/
|
||||||
|
CallActivityTest.java # unit (embedded)
|
||||||
|
|
||||||
|
src/test/java/.../e2e/
|
||||||
|
CallActivityE2ETest.java # E2E
|
||||||
|
ZorroWebAPI.java # HTTP клиент
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
### Нет parentProcessInstanceId
|
||||||
|
|
||||||
|
API не поддерживает фильтр `parentProcessInstanceId` в `ProcessInstance`. Поле `parentActivityId` — это UUID активити (callActivity элемента), а не ID родительского инстанса.
|
||||||
|
|
||||||
|
**Нельзя напрямую запросить:** "дай все дочерние процессы родителя X".
|
||||||
|
|
||||||
|
**Workaround:** найти последний инстанс с `parentActivityId != null` на последней странице. Работает для последовательных тестов, ненадёжно при параллельных.
|
||||||
|
|
||||||
|
**Желаемое улучшение API:**
|
||||||
|
```
|
||||||
|
GET /process-instances?parentProcessInstanceId={uuid}
|
||||||
|
```
|
||||||
|
|
||||||
|
Плюс добавить поле `parentProcessInstanceId` в объект `ProcessInstance`.
|
||||||
Reference in New Issue
Block a user