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:
ask 2026-05-17 08:25:20 +05:00
commit 93783ec006
23 changed files with 6139 additions and 0 deletions

22
.gitignore vendored Normal file
View 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
View 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
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View 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
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View 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
View 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">&lt; 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`.