Initial commit: Zorro domain registration prototype
- HTML frontend (admin, dashboard, index, login) - Node.js backend in server/ with auth, db, workers, domains, pdns, zorro - Project docs (PRD, TRD, summary, DNS), BPMN diagram
This commit is contained in:
commit
93783ec006
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
# secrets
|
||||
.jwt-secret
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# databases
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
# node
|
||||
node_modules/
|
||||
|
||||
# IDE / tooling
|
||||
.claude/
|
||||
.DS_Store
|
||||
|
||||
# separately versioned project (lives at vibe/gitea-pages)
|
||||
fin_vibe_landing_ju/
|
||||
287
DNS.md
Normal file
287
DNS.md
Normal file
@ -0,0 +1,287 @@
|
||||
# DNS — PowerDNS на Mac Studio
|
||||
|
||||
Локальный DNS-сервер: **PowerDNS Authoritative** (зоны + REST API) + **pdns-recursor** (входная точка на `:53`).
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
| | Где | Для чего |
|
||||
|---|---|---|
|
||||
| REST API | `http://127.0.0.1:8081/api/v1/servers/localhost` | управление зонами/записями |
|
||||
| Auth DNS | `127.0.0.1:5300` | для прямых `dig` к нашим зонам |
|
||||
| Recursor DNS | `0.0.0.0:53` (вся LAN) | резолвер для клиентов |
|
||||
|
||||
> В примерах ниже используется `203.0.113.10` — это RFC 5737 «documentation» IP (заменяйте на свой реальный, публичный или LAN).
|
||||
|
||||
## Авторизация
|
||||
|
||||
API-ключ в `~/.pdns-api-key`. Удобный alias:
|
||||
|
||||
```bash
|
||||
export PDNS_KEY=$(cat ~/.pdns-api-key)
|
||||
export PDNS_API=http://127.0.0.1:8081/api/v1/servers/localhost
|
||||
alias pdns='curl -sS -H "X-API-Key: $PDNS_KEY" -H "Content-Type: application/json"'
|
||||
```
|
||||
|
||||
Дальше все примеры ниже используют `$PDNS_KEY` и `$PDNS_API`.
|
||||
|
||||
---
|
||||
|
||||
## Поиск / просмотр
|
||||
|
||||
### Все зоны
|
||||
|
||||
```bash
|
||||
pdns $PDNS_API/zones | jq '.[].name'
|
||||
```
|
||||
|
||||
### Содержимое зоны `kt.` (все записи)
|
||||
|
||||
```bash
|
||||
pdns $PDNS_API/zones/kt. | jq '.rrsets[] | {name, type, records: [.records[].content]}'
|
||||
```
|
||||
|
||||
### Найти конкретную запись
|
||||
|
||||
```bash
|
||||
# поиск по имени по всем зонам
|
||||
pdns "$PDNS_API/search-data?q=crm.kt&max=20&object_type=record" | jq
|
||||
```
|
||||
|
||||
### Глобальный поиск (поддерживает wildcard `*`)
|
||||
|
||||
```bash
|
||||
pdns "$PDNS_API/search-data?q=*.asel.kt&max=50&object_type=record" | jq
|
||||
```
|
||||
|
||||
### Живая проверка (то, что реально отвечает DNS)
|
||||
|
||||
```bash
|
||||
# через recursor (так же ходят клиенты)
|
||||
dig asel.kt +short
|
||||
host crm.kt
|
||||
|
||||
# напрямую к auth (минуя recursor — полезно для отладки)
|
||||
dig @127.0.0.1 -p 5300 asel.kt +short
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Добавление / изменение записей
|
||||
|
||||
PowerDNS делает PATCH на зону, ключевое поле — `changetype: REPLACE` (создаёт или заменяет).
|
||||
|
||||
### A-запись
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [{
|
||||
"name": "grafana.kt.",
|
||||
"type": "A",
|
||||
"ttl": 60,
|
||||
"changetype": "REPLACE",
|
||||
"records": [{"content": "203.0.113.10", "disabled": false}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
Точка в конце имени (`grafana.kt.`) **обязательна** — FQDN.
|
||||
|
||||
### Несколько записей за раз
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [
|
||||
{"name": "app1.kt.", "type": "A", "ttl": 60, "changetype": "REPLACE",
|
||||
"records": [{"content": "203.0.113.10", "disabled": false}]},
|
||||
{"name": "app2.kt.", "type": "A", "ttl": 60, "changetype": "REPLACE",
|
||||
"records": [{"content": "203.0.113.10", "disabled": false}]},
|
||||
{"name": "db.kt.", "type": "A", "ttl": 60, "changetype": "REPLACE",
|
||||
"records": [{"content": "203.0.113.20", "disabled": false}]}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### CNAME (алиас)
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [{
|
||||
"name": "www.asel.kt.",
|
||||
"type": "CNAME",
|
||||
"ttl": 60,
|
||||
"changetype": "REPLACE",
|
||||
"records": [{"content": "asel.kt.", "disabled": false}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Wildcard
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [{
|
||||
"name": "*.dev.kt.",
|
||||
"type": "A",
|
||||
"ttl": 60,
|
||||
"changetype": "REPLACE",
|
||||
"records": [{"content": "203.0.113.10", "disabled": false}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
`*.dev.kt` → всё что угодно под `dev.kt` (`foo.dev.kt`, `bar.dev.kt`) резолвится в указанный IP.
|
||||
|
||||
### TXT
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [{
|
||||
"name": "_acme-challenge.asel.kt.",
|
||||
"type": "TXT",
|
||||
"ttl": 60,
|
||||
"changetype": "REPLACE",
|
||||
"records": [{"content": "\"your-token-here\"", "disabled": false}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
Кавычки внутри content **обязательны** для TXT (экранируются `\"`).
|
||||
|
||||
### MX
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [{
|
||||
"name": "asel.kt.",
|
||||
"type": "MX",
|
||||
"ttl": 300,
|
||||
"changetype": "REPLACE",
|
||||
"records": [{"content": "10 mail.asel.kt.", "disabled": false}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
|
||||
```bash
|
||||
pdns -X PATCH "$PDNS_API/zones/kt." -d '{
|
||||
"rrsets": [{
|
||||
"name": "grafana.kt.",
|
||||
"type": "A",
|
||||
"changetype": "DELETE"
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Новые зоны
|
||||
|
||||
### Создать зону
|
||||
|
||||
```bash
|
||||
pdns -X POST "$PDNS_API/zones" -d '{
|
||||
"name": "internal.kt.",
|
||||
"kind": "Native",
|
||||
"nameservers": ["ns1.kt."]
|
||||
}'
|
||||
```
|
||||
|
||||
После этого в recursor'е запросы на `.internal.kt` автоматом попадут в auth (т.к. он авторитативен, а recursor форвардит `.kt` целиком).
|
||||
|
||||
### Удалить зону
|
||||
|
||||
```bash
|
||||
pdns -X DELETE "$PDNS_API/zones/internal.kt."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сброс кэша recursor'а
|
||||
|
||||
После добавления/изменения записи клиенты (и сам recursor) могут видеть старое значение из-за TTL. Сбросить:
|
||||
|
||||
```bash
|
||||
# конкретное имя
|
||||
sudo -A /opt/homebrew/opt/pdnsrec/bin/rec_control \
|
||||
--socket-dir=/opt/homebrew/var/run/pdns-recursor wipe-cache asel.kt
|
||||
|
||||
# всю зону
|
||||
sudo -A /opt/homebrew/opt/pdnsrec/bin/rec_control \
|
||||
--socket-dir=/opt/homebrew/var/run/pdns-recursor wipe-cache kt$
|
||||
```
|
||||
|
||||
Суффикс `$` в аргументе `wipe-cache` = "и всё под этой зоной".
|
||||
|
||||
---
|
||||
|
||||
## Быстрые функции для zsh
|
||||
|
||||
Можно закинуть в `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
pdns_add_a() {
|
||||
# usage: pdns_add_a <name.kt> <ip>
|
||||
local name="$1" ip="$2"
|
||||
curl -sS -H "X-API-Key: $(cat ~/.pdns-api-key)" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X PATCH "http://127.0.0.1:8081/api/v1/servers/localhost/zones/kt." \
|
||||
-d "{\"rrsets\":[{\"name\":\"${name}.\",\"type\":\"A\",\"ttl\":60,\"changetype\":\"REPLACE\",\"records\":[{\"content\":\"${ip}\",\"disabled\":false}]}]}" \
|
||||
&& echo "added ${name} → ${ip}"
|
||||
sudo -A /opt/homebrew/opt/pdnsrec/bin/rec_control \
|
||||
--socket-dir=/opt/homebrew/var/run/pdns-recursor wipe-cache "$name" >/dev/null
|
||||
}
|
||||
|
||||
pdns_rm() {
|
||||
# usage: pdns_rm <name.kt> [type=A]
|
||||
local name="$1" type="${2:-A}"
|
||||
curl -sS -H "X-API-Key: $(cat ~/.pdns-api-key)" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X PATCH "http://127.0.0.1:8081/api/v1/servers/localhost/zones/kt." \
|
||||
-d "{\"rrsets\":[{\"name\":\"${name}.\",\"type\":\"${type}\",\"changetype\":\"DELETE\"}]}" \
|
||||
&& echo "removed ${name} (${type})"
|
||||
}
|
||||
|
||||
pdns_list() {
|
||||
# usage: pdns_list [zone=kt]
|
||||
local zone="${1:-kt}."
|
||||
curl -sS -H "X-API-Key: $(cat ~/.pdns-api-key)" \
|
||||
"http://127.0.0.1:8081/api/v1/servers/localhost/zones/${zone}" \
|
||||
| jq -r '.rrsets[] | "\(.name)\t\(.type)\t\(.records[].content)"' \
|
||||
| column -t
|
||||
}
|
||||
```
|
||||
|
||||
Примеры:
|
||||
```
|
||||
pdns_add_a grafana.kt 203.0.113.10 # IP — ваш публичный или внутренний
|
||||
pdns_list
|
||||
pdns_rm grafana.kt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Устройство (как это работает под капотом)
|
||||
|
||||
```
|
||||
Клиент (Mac / LAN-ноут / docker-контейнер)
|
||||
│
|
||||
▼ запрос к 127.0.0.1:53 (Mac) или <LAN-IP>:53 (офисные ноуты) или 192.168.5.2:53 (контейнер через lima hostResolver → Mac system DNS)
|
||||
pdns-recursor (:53)
|
||||
├── `.kt` → форвард в 127.0.0.1:5300
|
||||
└── всё остальное → рекурсивно через root hints / fallback 1.1.1.1
|
||||
│
|
||||
▼
|
||||
публичный DNS
|
||||
pdns-auth (:5300) ← авторитативный
|
||||
├── SQLite /opt/homebrew/var/powerdns/pdns.sqlite3
|
||||
└── REST API (:8081) ← сюда мы пишем
|
||||
```
|
||||
|
||||
- Конфиги: `/opt/homebrew/etc/powerdns/pdns.conf` (auth), `/opt/homebrew/etc/powerdns/recursor.yml` (recursor).
|
||||
- Логи: `/opt/homebrew/var/log/pdnsrec.log`.
|
||||
- Старт/стоп:
|
||||
- `brew services start/stop pdns` (auth)
|
||||
- `sudo -A launchctl bootstrap/bootout system /Library/LaunchDaemons/homebrew.mxcl.pdnsrec.plist` (recursor)
|
||||
744
admin.html
Normal file
744
admin.html
Normal file
@ -0,0 +1,744 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Панель администратора — domain.kt</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--blue: #005baa;
|
||||
--blue-dark: #003f7a;
|
||||
--blue-light: #e8f0fb;
|
||||
--green: #1ab36e;
|
||||
--green-bg: #e6f9f0;
|
||||
--orange: #f07200;
|
||||
--orange-bg: #fff3e6;
|
||||
--red: #d93636;
|
||||
--red-bg: #fdeaea;
|
||||
--purple: #7c3aed;
|
||||
--purple-bg: #f3f0ff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--bg: #f3f6fb;
|
||||
--card: #ffffff;
|
||||
--sidebar-w: 240px;
|
||||
--header-h: 60px;
|
||||
--radius: 12px;
|
||||
--shadow: 0 2px 12px rgba(0,91,170,.08);
|
||||
}
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background: var(--bg); color: var(--text); }
|
||||
|
||||
/* ── SIDEBAR ──────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w);
|
||||
background: #1a1a2e; display: flex; flex-direction: column; z-index: 100;
|
||||
}
|
||||
.sidebar-logo {
|
||||
display: flex; align-items: center; gap: 10px; padding: 18px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08); text-decoration: none;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.sidebar-logo:hover { opacity: .8; }
|
||||
.dot-kt { background: #60a5fa; color: #1a1a2e; font-size: .72rem; font-weight: 800; padding: 2px 7px; border-radius: 5px; }
|
||||
.logo-text { color: #fff; font-weight: 700; font-size: .95rem; }
|
||||
.logo-text span { display: block; font-size: .65rem; opacity: .45; font-weight: 400; color: #f87171; }
|
||||
|
||||
.sidebar-nav { flex: 1; padding: 10px 0; overflow-y: auto; }
|
||||
.nav-label { padding: 8px 18px 3px; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: rgba(255,255,255,.3); }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 18px;
|
||||
color: rgba(255,255,255,.7); cursor: pointer; border: none; background: none;
|
||||
width: 100%; text-align: left; font-size: .85rem; text-decoration: none;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nav-item:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||
.nav-item.active { background: rgba(255,255,255,.1); color: #fff; border-left: 3px solid #f87171; }
|
||||
.nav-item .badge {
|
||||
margin-left: auto; padding: 1px 6px; border-radius: 20px;
|
||||
font-size: .62rem; font-weight: 700; color: #fff;
|
||||
}
|
||||
.badge.red { background: var(--red); }
|
||||
.badge.orange { background: var(--orange); }
|
||||
.badge.green { background: var(--green); }
|
||||
|
||||
.sidebar-user { padding: 12px 18px; border-top: 1px solid rgba(255,255,255,.08); display: flex; align-items: center; gap: 10px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: #f87171; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .78rem; flex-shrink: 0; }
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.user-name { color: #fff; font-size: .82rem; font-weight: 600; }
|
||||
.user-role { color: rgba(255,255,255,.4); font-size: .68rem; }
|
||||
|
||||
/* ── HEADER ───────────────────────────────────────────────── */
|
||||
.header {
|
||||
position: fixed; top: 0; left: var(--sidebar-w); right: 0; height: var(--header-h);
|
||||
background: var(--card); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 28px; gap: 16px; z-index: 90;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.05);
|
||||
}
|
||||
.header-title { font-size: 1rem; font-weight: 700; flex: 1; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ── MAIN ─────────────────────────────────────────────────── */
|
||||
.main { margin-left: var(--sidebar-w); margin-top: var(--header-h); padding: 24px; }
|
||||
.page { display: none; animation: fadeIn .18s; }
|
||||
.page.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:none; } }
|
||||
|
||||
/* ── KPI ──────────────────────────────────────────────────── */
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px,1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.kpi { background: var(--card); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); display: flex; align-items: center; gap: 12px; }
|
||||
.kpi-icon { width: 40px; height: 40px; border-radius: 9px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; }
|
||||
.kpi-icon.blue { background: var(--blue-light); }
|
||||
.kpi-icon.green { background: var(--green-bg); }
|
||||
.kpi-icon.orange { background: var(--orange-bg); }
|
||||
.kpi-icon.red { background: var(--red-bg); }
|
||||
.kpi-icon.purple { background: var(--purple-bg); }
|
||||
.kpi-val { font-size: 1.4rem; font-weight: 800; line-height: 1; }
|
||||
.kpi-label { font-size: .72rem; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ── FILTER BAR ───────────────────────────────────────────── */
|
||||
.filter-bar {
|
||||
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;
|
||||
}
|
||||
.filter-input {
|
||||
flex: 1; min-width: 200px; padding: 9px 12px; border: 1.5px solid var(--border);
|
||||
border-radius: 8px; font-size: .86rem; outline: none; background: var(--card);
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.filter-input:focus { border-color: var(--blue); }
|
||||
.filter-select {
|
||||
padding: 9px 12px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||
font-size: .86rem; background: var(--card); outline: none; cursor: pointer;
|
||||
}
|
||||
.filter-select:focus { border-color: var(--blue); }
|
||||
|
||||
/* ── TABLE ────────────────────────────────────────────────── */
|
||||
.table-card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .83rem; }
|
||||
thead th {
|
||||
padding: 11px 14px; text-align: left; font-size: .7rem;
|
||||
text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted);
|
||||
border-bottom: 2px solid var(--border); background: var(--bg); font-weight: 600;
|
||||
}
|
||||
tbody td { padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: var(--blue-light); }
|
||||
tbody tr.conflict-row td { background: #fff8f0; }
|
||||
tbody tr.conflict-row:hover td { background: var(--orange-bg); }
|
||||
|
||||
.domain-cell { font-weight: 700; font-family: monospace; font-size: .82rem; }
|
||||
.domain-cell .kt { color: var(--blue); font-weight: 400; }
|
||||
|
||||
.status-pill { padding: 3px 9px; border-radius: 20px; font-size: .69rem; font-weight: 700; white-space: nowrap; }
|
||||
.status-pill.active { background: var(--green-bg); color: var(--green); }
|
||||
.status-pill.pending { background: var(--orange-bg); color: var(--orange); }
|
||||
.status-pill.conflict { background: var(--red-bg); color: var(--red); }
|
||||
.status-pill.rejected { background: #f1f1f1; color: var(--text-muted); }
|
||||
|
||||
.action-btns { display: flex; gap: 6px; flex-wrap: nowrap; }
|
||||
.btn-act {
|
||||
padding: 5px 10px; border-radius: 6px; font-size: .75rem; font-weight: 600;
|
||||
cursor: pointer; border: 1px solid var(--border); background: var(--bg);
|
||||
color: var(--text); transition: background .12s, border-color .12s; white-space: nowrap;
|
||||
}
|
||||
.btn-act:hover { background: var(--blue-light); border-color: var(--blue); color: var(--blue); }
|
||||
.btn-act.approve:hover { background: var(--green-bg); border-color: var(--green); color: var(--green); }
|
||||
.btn-act.reject:hover { background: var(--red-bg); border-color: var(--red); color: var(--red); }
|
||||
.btn-act.reassign:hover { background: var(--orange-bg); border-color: var(--orange); color: var(--orange); }
|
||||
|
||||
/* ── CONFLICT BADGE ───────────────────────────────────────── */
|
||||
.conflict-flag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--orange-bg); color: var(--orange);
|
||||
font-size: .7rem; font-weight: 700; padding: 2px 8px; border-radius: 20px;
|
||||
}
|
||||
|
||||
/* ── MODAL ────────────────────────────────────────────────── */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; z-index: 200; }
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal { background: var(--card); border-radius: 16px; width: 480px; max-width: 94vw; padding: 28px; box-shadow: 0 12px 48px rgba(0,0,0,.2); animation: popIn .18s; }
|
||||
@keyframes popIn { from { opacity:0; transform:scale(.94); } to { opacity:1; transform:none; } }
|
||||
.modal-title { font-size: 1rem; font-weight: 700; margin-bottom: 4px; }
|
||||
.modal-sub { font-size: .82rem; color: var(--text-muted); margin-bottom: 20px; }
|
||||
.modal-info { background: var(--bg); border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: .82rem; line-height: 1.6; }
|
||||
.modal-info strong { display: block; margin-bottom: 2px; }
|
||||
.modal-actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: .74rem; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; text-transform: uppercase; }
|
||||
.field input, .field select, .field textarea { width: 100%; padding: 9px 12px; border: 1.5px solid var(--border); border-radius: 8px; font-size: .86rem; background: var(--bg); color: var(--text); outline: none; transition: border-color .15s; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--blue); background: #fff; }
|
||||
.btn-cancel { flex:1; padding:9px; border-radius:8px; font-size:.84rem; font-weight:600; border:1px solid var(--border); background:var(--bg); color:var(--text); cursor:pointer; }
|
||||
.btn-confirm { flex:1; padding:9px; border-radius:8px; font-size:.84rem; font-weight:700; background:var(--blue); color:#fff; border:none; cursor:pointer; }
|
||||
.btn-confirm.red { background:var(--red); }
|
||||
.btn-confirm.orange { background:var(--orange); }
|
||||
.btn-cancel:hover { background:var(--border); }
|
||||
.btn-confirm:hover { filter:brightness(1.1); }
|
||||
|
||||
/* ── TOAST ────────────────────────────────────────────────── */
|
||||
.toast { position:fixed; bottom:24px; right:24px; background:var(--text); color:#fff; padding:11px 18px; border-radius:9px; font-size:.84rem; font-weight:500; transform:translateY(60px); opacity:0; transition:transform .25s,opacity .25s; z-index:300; }
|
||||
.toast.show { transform:translateY(0); opacity:1; }
|
||||
.toast.green { background:var(--green); }
|
||||
.toast.red { background:var(--red); }
|
||||
.toast.orange { background:var(--orange); }
|
||||
|
||||
/* ── SECTION TITLE ────────────────────────────────────────── */
|
||||
.sec-title { font-size:.71rem; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); font-weight:600; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
||||
.sec-title::after { content:''; flex:1; height:1px; background:var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── SIDEBAR ──────────────────────────────────────────────────── -->
|
||||
<aside class="sidebar">
|
||||
<a href="index.html" class="sidebar-logo">
|
||||
<span class="dot-kt">.kt</span>
|
||||
<div class="logo-text">domain.kt <span>⚙ Панель администратора</span></div>
|
||||
</a>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-label">Управление</div>
|
||||
<a href="#" class="nav-item active" data-page="all-domains">
|
||||
🌐 Все домены <span class="badge green">1 240</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="pending">
|
||||
⏳ На проверке <span class="badge orange">14</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="conflicts">
|
||||
⚠️ Конфликты <span class="badge red">3</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="employees">
|
||||
👥 Сотрудники
|
||||
</a>
|
||||
<div class="nav-label">Система</div>
|
||||
<a href="#" class="nav-item" data-page="audit">
|
||||
📋 Журнал действий
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="settings">
|
||||
⚙️ Настройки системы
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-user">
|
||||
<div class="avatar">ИА</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">Иван Ахметов</div>
|
||||
<div class="user-role">IT-администратор</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── HEADER ────────────────────────────────────────────────────── -->
|
||||
<header class="header">
|
||||
<div class="header-title" id="header-title">Все домены</div>
|
||||
<div class="header-actions">
|
||||
<a href="dashboard.html" style="padding:7px 14px;border-radius:8px;border:1px solid var(--border);background:var(--bg);font-size:.82rem;color:var(--text);text-decoration:none;font-weight:600;">
|
||||
→ Кабинет сотрудника
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── MAIN ──────────────────────────────────────────────────────── -->
|
||||
<main class="main">
|
||||
|
||||
<!-- ════ PAGE: all-domains ════ -->
|
||||
<div class="page active" id="page-all-domains">
|
||||
|
||||
<div class="kpi-row">
|
||||
<div class="kpi"><div class="kpi-icon blue">🌐</div><div><div class="kpi-val">1 240</div><div class="kpi-label">Доменов всего</div></div></div>
|
||||
<div class="kpi"><div class="kpi-icon green">✅</div><div><div class="kpi-val">1 218</div><div class="kpi-label">Активных</div></div></div>
|
||||
<div class="kpi"><div class="kpi-icon orange">⏳</div><div><div class="kpi-val">14</div><div class="kpi-label">На проверке</div></div></div>
|
||||
<div class="kpi"><div class="kpi-icon red">⚠️</div><div><div class="kpi-val">3</div><div class="kpi-label">Конфликтов</div></div></div>
|
||||
<div class="kpi"><div class="kpi-icon purple">👥</div><div><div class="kpi-val">3 812</div><div class="kpi-label">Сотрудников</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title">🌐 Реестр доменов .kt</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input class="filter-input" id="filter-search" type="text" placeholder="🔍 Поиск по домену, CDN или имени..." oninput="filterTable()" />
|
||||
<select class="filter-select" id="filter-status" onchange="filterTable()">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="pending">На проверке</option>
|
||||
<option value="conflict">Конфликты</option>
|
||||
<option value="rejected">Отклонённые</option>
|
||||
</select>
|
||||
<select class="filter-select" id="filter-type" onchange="filterTable()">
|
||||
<option value="">Все типы</option>
|
||||
<option value="personal">Персональные</option>
|
||||
<option value="project">Проектные</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<table id="domains-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Домен</th>
|
||||
<th>CDN владельца</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domains-tbody">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- /page-all-domains -->
|
||||
|
||||
<!-- ════ PAGE: pending ════ -->
|
||||
<div class="page" id="page-pending">
|
||||
<div class="sec-title">⏳ Заявки на проверке</div>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Домен</th>
|
||||
<th>CDN</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Назначение</th>
|
||||
<th>Подано</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pending-tbody"><!-- populated by JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- /page-pending -->
|
||||
|
||||
<!-- ════ PAGE: conflicts ════ -->
|
||||
<div class="page" id="page-conflicts">
|
||||
<div class="sec-title">⚠️ Конфликты — однофамильцы и спорные имена</div>
|
||||
|
||||
<!-- Conflict 1: same surname -->
|
||||
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:18px;overflow:hidden;">
|
||||
<div style="background:var(--orange-bg);border-bottom:1px solid var(--border);padding:14px 18px;display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-size:1.1rem;">⚠️</span>
|
||||
<div>
|
||||
<strong style="font-size:.9rem;">Конфликт: однофамильцы</strong>
|
||||
<span style="font-size:.78rem;color:var(--text-muted);margin-left:8px;">Два сотрудника претендуют на <code>tuleev.kt</code></span>
|
||||
</div>
|
||||
<span class="status-pill conflict" style="margin-left:auto;">Требует решения</span>
|
||||
</div>
|
||||
<div style="padding:16px 18px;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.83rem;margin-bottom:14px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--border);">
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">CDN</th>
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Сотрудник</th>
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Отдел</th>
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Дата заявки</th>
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--text-muted);font-size:.7rem;text-transform:uppercase;">Приоритет</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="background:var(--green-bg);">
|
||||
<td style="padding:9px 10px;font-family:monospace;">tuleev.a</td>
|
||||
<td style="padding:9px 10px;font-weight:600;">Аскар Тулеев</td>
|
||||
<td style="padding:9px 10px;color:var(--text-muted);">Цифровые продукты</td>
|
||||
<td style="padding:9px 10px;color:var(--text-muted);">10 апр 2026</td>
|
||||
<td style="padding:9px 10px;"><span class="status-pill active">Первый</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:9px 10px;font-family:monospace;">tuleev.d</td>
|
||||
<td style="padding:9px 10px;font-weight:600;">Данияр Тулеев</td>
|
||||
<td style="padding:9px 10px;color:var(--text-muted);">Сетевые технологии</td>
|
||||
<td style="padding:9px 10px;color:var(--text-muted);">11 апр 2026</td>
|
||||
<td style="padding:9px 10px;"><span class="status-pill pending">Второй</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn-act approve" onclick="resolveConflict('tuleev.a','tuleev.kt','tuleev.d','tuleev-d.kt')">
|
||||
✓ Назначить tuleev.a → tuleev.kt, tuleev.d → tuleev-d.kt
|
||||
</button>
|
||||
<button class="btn-act reassign" onclick="openReassign('tuleev.kt')">
|
||||
↩ Переназначить вручную
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict 2: reserved name -->
|
||||
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;">
|
||||
<div style="background:var(--red-bg);border-bottom:1px solid var(--border);padding:14px 18px;display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-size:1.1rem;">🚫</span>
|
||||
<div>
|
||||
<strong style="font-size:.9rem;">Зарезервированное имя</strong>
|
||||
<span style="font-size:.78rem;color:var(--text-muted);margin-left:8px;">Сотрудник запросил <code>admin.kt</code> — системное имя</span>
|
||||
</div>
|
||||
<span class="status-pill conflict" style="margin-left:auto;">Заблокировано</span>
|
||||
</div>
|
||||
<div style="padding:16px 18px;">
|
||||
<div style="font-size:.84rem;margin-bottom:14px;">
|
||||
<strong>Заявитель:</strong> Берик Асанов (<code>asanov.b</code>) · Дата: 12 апр 2026<br>
|
||||
<span style="color:var(--text-muted);">Имя <strong>admin</strong> зарезервировано для системных нужд и не может быть выдано сотрудникам.</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn-act reject" onclick="toastMsg('Заявка отклонена, сотрудник уведомлён','red')">
|
||||
✗ Отклонить и уведомить
|
||||
</button>
|
||||
<button class="btn-act" onclick="toastMsg('Предложены альтернативы: asanov.kt, asanov-b.kt','orange')">
|
||||
💡 Предложить альтернативы
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /page-conflicts -->
|
||||
|
||||
<!-- ════ PAGE: employees ════ -->
|
||||
<div class="page" id="page-employees">
|
||||
<div class="sec-title">👥 Сотрудники в системе</div>
|
||||
<div class="filter-bar">
|
||||
<input class="filter-input" type="text" placeholder="🔍 Поиск по имени или CDN..." />
|
||||
<select class="filter-select"><option>Все отделы</option><option>Цифровые продукты</option><option>Сетевые технологии</option><option>IT-безопасность</option></select>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CDN</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Email</th>
|
||||
<th>Отдел</th>
|
||||
<th>Доменов</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>tuleev.a</code></td>
|
||||
<td style="font-weight:600;">Аскар Тулеев</td>
|
||||
<td style="color:var(--text-muted);">tuleev.a@telecom.kz</td>
|
||||
<td style="color:var(--text-muted);">Цифровые продукты</td>
|
||||
<td>3</td>
|
||||
<td><span class="status-pill active">Активен</span></td>
|
||||
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Аскар Тулеев...')">Профиль</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bekova.m</code></td>
|
||||
<td style="font-weight:600;">Мадина Бекова</td>
|
||||
<td style="color:var(--text-muted);">bekova.m@telecom.kz</td>
|
||||
<td style="color:var(--text-muted);">IT-безопасность</td>
|
||||
<td>2</td>
|
||||
<td><span class="status-pill active">Активен</span></td>
|
||||
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Мадина Бекова...')">Профиль</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>tuleev.d</code></td>
|
||||
<td style="font-weight:600;">Данияр Тулеев</td>
|
||||
<td style="color:var(--text-muted);">tuleev.d@telecom.kz</td>
|
||||
<td style="color:var(--text-muted);">Сетевые технологии</td>
|
||||
<td>1</td>
|
||||
<td><span class="status-pill conflict">Конфликт</span></td>
|
||||
<td><button class="btn-act" onclick="showPage('conflicts')">→ Конфликт</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>asanov.b</code></td>
|
||||
<td style="font-weight:600;">Берик Асанов</td>
|
||||
<td style="color:var(--text-muted);">asanov.b@telecom.kz</td>
|
||||
<td style="color:var(--text-muted);">Финансы</td>
|
||||
<td>1</td>
|
||||
<td><span class="status-pill pending">На проверке</span></td>
|
||||
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Берик Асанов...')">Профиль</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dzhunusov.t</code></td>
|
||||
<td style="font-weight:600;">Тимур Джунусов</td>
|
||||
<td style="color:var(--text-muted);">dzhunusov.t@telecom.kz</td>
|
||||
<td style="color:var(--text-muted);">Разработка</td>
|
||||
<td>4</td>
|
||||
<td><span class="status-pill active">Активен</span></td>
|
||||
<td><button class="btn-act" onclick="toastMsg('Открытие профиля Тимур Джунусов...')">Профиль</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- /page-employees -->
|
||||
|
||||
<!-- ════ PAGE: audit ════ -->
|
||||
<div class="page" id="page-audit">
|
||||
<div class="sec-title">📋 Журнал действий администратора</div>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Администратор</th>
|
||||
<th>Действие</th>
|
||||
<th>Объект</th>
|
||||
<th>Результат</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td style="color:var(--text-muted);">14 апр 2026, 09:12</td><td>akhmetov.i</td><td>Одобрение домена</td><td><code>devops-team.kt</code></td><td><span class="status-pill active">Выполнено</span></td></tr>
|
||||
<tr><td style="color:var(--text-muted);">13 апр 2026, 16:44</td><td>akhmetov.i</td><td>Отклонение домена</td><td><code>old-project.kt</code></td><td><span class="status-pill rejected">Отклонено</span></td></tr>
|
||||
<tr><td style="color:var(--text-muted);">13 апр 2026, 11:05</td><td>akhmetov.i</td><td>Разрешение конфликта</td><td><code>bekova.kt</code></td><td><span class="status-pill active">Выполнено</span></td></tr>
|
||||
<tr><td style="color:var(--text-muted);">12 апр 2026, 14:30</td><td>akhmetov.i</td><td>Блокировка имени</td><td><code>system.kt</code></td><td><span class="status-pill rejected">Заблокировано</span></td></tr>
|
||||
<tr><td style="color:var(--text-muted);">11 апр 2026, 10:20</td><td>akhmetov.i</td><td>Переназначение домена</td><td><code>hr.kt</code></td><td><span class="status-pill active">Выполнено</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- /page-audit -->
|
||||
|
||||
<!-- ════ PAGE: settings ════ -->
|
||||
<div class="page" id="page-settings">
|
||||
<div style="max-width:520px;">
|
||||
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:16px;">
|
||||
<div style="font-size:.9rem;font-weight:700;margin-bottom:16px;">Правила авто-регистрации</div>
|
||||
<div class="field"><label>Авто-одобрение персональных доменов</label>
|
||||
<select><option>Включено — мгновенно</option><option>Выключено — через администратора</option></select>
|
||||
</div>
|
||||
<div class="field"><label>Срок рассмотрения проектных заявок (дни)</label>
|
||||
<input type="number" value="2" min="1" max="14" />
|
||||
</div>
|
||||
<div class="field"><label>Максимум доменов на одного сотрудника</label>
|
||||
<input type="number" value="10" min="1" />
|
||||
</div>
|
||||
<button style="padding:9px 20px;border-radius:8px;background:var(--blue);color:#fff;border:none;cursor:pointer;font-weight:700;font-size:.85rem;" onclick="toastMsg('Настройки сохранены','green')">Сохранить</button>
|
||||
</div>
|
||||
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;">
|
||||
<div style="font-size:.9rem;font-weight:700;margin-bottom:16px;">Зарезервированные имена</div>
|
||||
<div style="font-size:.82rem;color:var(--text-muted);margin-bottom:10px;">Эти имена не могут быть зарегистрированы сотрудниками:</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;">
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">admin</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">mail</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">vpn</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">it</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">corp</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">support</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">intranet</span>
|
||||
<span style="background:var(--red-bg);color:var(--red);padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600;">system</span>
|
||||
</div>
|
||||
<button style="padding:7px 14px;border-radius:8px;border:1px solid var(--border);background:var(--bg);font-size:.82rem;cursor:pointer;" onclick="toastMsg('Добавление нового зарезервированного имени...')">+ Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /page-settings -->
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ── REJECT MODAL ───────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-reject">
|
||||
<div class="modal">
|
||||
<div class="modal-title">Отклонить заявку</div>
|
||||
<div class="modal-sub">Домен: <strong id="reject-domain-name"></strong></div>
|
||||
<div class="field"><label>Причина отклонения</label>
|
||||
<textarea id="reject-reason" rows="3" placeholder="Укажите причину для сотрудника..."></textarea>
|
||||
</div>
|
||||
<div class="field"><label>Предложить альтернативу (необязательно)</label>
|
||||
<input type="text" id="reject-alt" placeholder="например: asanov-b.kt" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeModals()">Отмена</button>
|
||||
<button class="btn-confirm red" onclick="confirmReject()">Отклонить и уведомить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── REASSIGN MODAL ─────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-reassign">
|
||||
<div class="modal">
|
||||
<div class="modal-title">Переназначить домен</div>
|
||||
<div class="modal-sub">Домен: <strong id="reassign-domain-name"></strong></div>
|
||||
<div class="field"><label>Назначить сотруднику (CDN)</label>
|
||||
<input type="text" id="reassign-cdn" placeholder="tuleev.a" />
|
||||
</div>
|
||||
<div class="field"><label>Комментарий</label>
|
||||
<textarea id="reassign-comment" rows="2" placeholder="Причина переназначения..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeModals()">Отмена</button>
|
||||
<button class="btn-confirm orange" onclick="confirmReassign()">Переназначить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TOAST ──────────────────────────────────────────────────────── -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script type="module">
|
||||
import { api, requireAuth, logout, formatDate } from './app.js';
|
||||
|
||||
const user = requireAuth({ admin: true });
|
||||
if (!user) throw new Error('redirecting');
|
||||
|
||||
const logoutBtn = document.querySelector('.logout-btn');
|
||||
if (logoutBtn) logoutBtn.onclick = () => logout();
|
||||
|
||||
const STATUS_LABEL = { active:'Активен', pending:'На проверке', conflict:'Конфликт', inactive:'Отклонён', rejected:'Отклонён' };
|
||||
const TYPE_LABEL = { personal:'Персональный', project:'Проектный' };
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
let allDomains = [];
|
||||
let pendingTasks = [];
|
||||
|
||||
async function loadAllDomains() {
|
||||
allDomains = await api('/admin/domains');
|
||||
renderAllTable();
|
||||
updateKpiAll();
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
pendingTasks = await api('/admin/domains?status=pending');
|
||||
} catch (e) {
|
||||
pendingTasks = [];
|
||||
console.error(e);
|
||||
}
|
||||
renderPendingTable();
|
||||
}
|
||||
|
||||
function updateKpiAll() {
|
||||
const active = allDomains.filter(d => d.status === 'active').length;
|
||||
const pending = pendingTasks.length;
|
||||
const kpis = document.querySelectorAll('#page-all-domains .kpi-val');
|
||||
if (kpis[0]) kpis[0].textContent = allDomains.length;
|
||||
if (kpis[1]) kpis[1].textContent = active;
|
||||
if (kpis[2]) kpis[2].textContent = pending;
|
||||
if (kpis[3]) kpis[3].textContent = allDomains.filter(d => d.status === 'conflict').length;
|
||||
}
|
||||
|
||||
function renderAllTable() {
|
||||
const q = document.getElementById('filter-search')?.value.toLowerCase() || '';
|
||||
const status = document.getElementById('filter-status')?.value || '';
|
||||
const type = document.getElementById('filter-type')?.value || '';
|
||||
const rows = allDomains.filter(d =>
|
||||
(!q || d.name.includes(q) || d.owner_cdn?.includes(q) || d.owner_name?.toLowerCase().includes(q)) &&
|
||||
(!status || d.status === status) &&
|
||||
(!type || d.type === type)
|
||||
);
|
||||
document.getElementById('domains-tbody').innerHTML = rows.map(d => `
|
||||
<tr class="${d.status==='conflict'?'conflict-row':''}">
|
||||
<td class="domain-cell">${escapeHtml(d.name)}<span class="kt">.kt</span></td>
|
||||
<td><code>${escapeHtml(d.owner_cdn || '')}</code></td>
|
||||
<td>${escapeHtml(d.owner_name || '')}</td>
|
||||
<td style="color:var(--text-muted);">${TYPE_LABEL[d.type] || d.type}</td>
|
||||
<td><span class="status-pill ${d.status}">${STATUS_LABEL[d.status] || d.status}</span></td>
|
||||
<td style="color:var(--text-muted);">${formatDate(d.created_at)}</td>
|
||||
<td><div class="action-btns">
|
||||
${d.status==='active' ? `<span style="color:var(--text-muted);font-size:.78rem;">A ${escapeHtml(d.dns_record || '')}</span>` : ''}
|
||||
</div></td>
|
||||
</tr>`).join('') || `<tr><td colspan="7" style="padding:20px;text-align:center;color:var(--text-muted);">Нет доменов</td></tr>`;
|
||||
}
|
||||
|
||||
function renderPendingTable() {
|
||||
const tbody = document.getElementById('pending-tbody');
|
||||
if (!tbody) return;
|
||||
if (!pendingTasks.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="padding:20px;text-align:center;color:var(--text-muted);">Нет заявок на проверке</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = pendingTasks.map(t => `
|
||||
<tr>
|
||||
<td class="domain-cell">${escapeHtml(t.name)}<span class="kt">.kt</span></td>
|
||||
<td><code>${escapeHtml(t.owner_cdn || '')}</code></td>
|
||||
<td>${escapeHtml(t.owner_email || '')}</td>
|
||||
<td>${escapeHtml(t.purpose || '—')}</td>
|
||||
<td style="color:var(--text-muted);">${formatDate(t.createdAt)}</td>
|
||||
<td><div class="action-btns">
|
||||
<button class="btn-act approve" data-task="${t.userTaskId}" data-name="${escapeHtml(t.fqdn)}">✓ Одобрить</button>
|
||||
<button class="btn-act reject" data-task="${t.userTaskId}" data-name="${escapeHtml(t.fqdn)}">✗ Отклонить</button>
|
||||
</div></td>
|
||||
</tr>`).join('');
|
||||
|
||||
tbody.querySelectorAll('.btn-act.approve').forEach(btn => {
|
||||
btn.onclick = () => approveTask(btn.dataset.task, btn.dataset.name);
|
||||
});
|
||||
tbody.querySelectorAll('.btn-act.reject').forEach(btn => {
|
||||
btn.onclick = () => openReject(btn.dataset.task, btn.dataset.name);
|
||||
});
|
||||
}
|
||||
|
||||
async function approveTask(taskId, domain) {
|
||||
try {
|
||||
await api(`/admin/user-tasks/${taskId}/approve`, { method: 'POST', body: { comment: '' } });
|
||||
toastMsg(`✓ ${domain} одобрен, Zorro провизионит DNS`, 'green');
|
||||
setTimeout(() => { loadPending(); loadAllDomains(); }, 3000);
|
||||
setTimeout(() => { loadPending(); loadAllDomains(); }, 8000);
|
||||
} catch (e) {
|
||||
toastMsg(`Ошибка: ${e.message}`, 'red');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nav ─────────────────────────────────────────────────────────
|
||||
const PAGE_TITLES = {
|
||||
'all-domains':'Все домены','pending':'На проверке','conflicts':'Конфликты',
|
||||
'employees':'Сотрудники','audit':'Журнал действий','settings':'Настройки системы'
|
||||
};
|
||||
function showPage(id) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
const pg = document.getElementById('page-'+id);
|
||||
if (pg) { pg.classList.add('active'); window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
const ni = document.querySelector(`.nav-item[data-page="${id}"]`);
|
||||
if (ni) ni.classList.add('active');
|
||||
document.getElementById('header-title').textContent = PAGE_TITLES[id] || id;
|
||||
if (id === 'all-domains') loadAllDomains();
|
||||
if (id === 'pending') loadPending();
|
||||
}
|
||||
window.showPage = showPage;
|
||||
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||
item.addEventListener('click', e => { e.preventDefault(); showPage(item.dataset.page); });
|
||||
});
|
||||
|
||||
window.filterTable = renderAllTable;
|
||||
|
||||
// ── Reject modal ────────────────────────────────────────────────
|
||||
let rejectTarget = null;
|
||||
function openReject(taskId, domainName) {
|
||||
rejectTarget = taskId;
|
||||
document.getElementById('reject-domain-name').textContent = domainName;
|
||||
document.getElementById('reject-reason').value = '';
|
||||
document.getElementById('reject-alt').value = '';
|
||||
document.getElementById('modal-reject').classList.add('open');
|
||||
}
|
||||
window.openReject = openReject;
|
||||
window.confirmReject = async function() {
|
||||
const reason = document.getElementById('reject-reason').value.trim();
|
||||
closeModals();
|
||||
try {
|
||||
await api(`/admin/user-tasks/${rejectTarget}/reject`, { method: 'POST', body: { comment: reason } });
|
||||
toastMsg('Заявка отклонена. Сотрудник уведомлён.', 'red');
|
||||
setTimeout(() => { loadPending(); loadAllDomains(); }, 2000);
|
||||
} catch (e) {
|
||||
toastMsg(`Ошибка: ${e.message}`, 'red');
|
||||
}
|
||||
};
|
||||
window.openReassign = (d) => toastMsg(`Переназначение пока не реализовано`, 'orange');
|
||||
window.confirmReassign = () => closeModals();
|
||||
window.resolveConflict = () => toastMsg('Конфликты пока не реализованы', 'orange');
|
||||
|
||||
function closeModals() {
|
||||
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
|
||||
}
|
||||
window.closeModals = closeModals;
|
||||
document.querySelectorAll('.modal-overlay').forEach(m => {
|
||||
m.addEventListener('click', e => { if (e.target === m) closeModals(); });
|
||||
});
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────────
|
||||
let toastTimer;
|
||||
function toastMsg(msg, type='') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show' + (type?' '+type:'');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(()=>{ t.className='toast'; }, 3500);
|
||||
}
|
||||
window.toastMsg = toastMsg;
|
||||
|
||||
// ── Initial load + auto-refresh pending every 5s ─────────────────
|
||||
loadAllDomains();
|
||||
loadPending();
|
||||
setInterval(() => { if (document.getElementById('page-pending')?.classList.contains('active')) loadPending(); }, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
60
app.js
Normal file
60
app.js
Normal file
@ -0,0 +1,60 @@
|
||||
// Shared API client for domain.kt frontend
|
||||
const API_BASE = ''; // same origin (backend serves static)
|
||||
const TOKEN_KEY = 'domain_kt_token';
|
||||
const USER_KEY = 'domain_kt_user';
|
||||
|
||||
export function getToken() { return localStorage.getItem(TOKEN_KEY); }
|
||||
export function getUser() { try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); } catch { return null; } }
|
||||
|
||||
export function saveSession(token, user) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
window.location.href = 'login.html';
|
||||
}
|
||||
|
||||
export async function api(path, options = {}) {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
body: options.body ? (typeof options.body === 'string' ? options.body : JSON.stringify(options.body)) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function requireAuth(opts = {}) {
|
||||
const u = getUser();
|
||||
if (!u || !getToken()) { window.location.href = 'login.html'; return null; }
|
||||
if (opts.admin && u.role !== 'admin') {
|
||||
alert('Admin only');
|
||||
window.location.href = 'dashboard.html';
|
||||
return null;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
export function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.replace(' ', 'T') + (iso.includes('Z') ? '' : 'Z'));
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
748
dashboard.html
Normal file
748
dashboard.html
Normal file
@ -0,0 +1,748 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Личный кабинет — domain.kt</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--blue: #005baa;
|
||||
--blue-dark: #003f7a;
|
||||
--blue-light: #e8f0fb;
|
||||
--green: #1ab36e;
|
||||
--green-bg: #e6f9f0;
|
||||
--orange: #f07200;
|
||||
--orange-bg: #fff3e6;
|
||||
--red: #d93636;
|
||||
--red-bg: #fdeaea;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--bg: #f3f6fb;
|
||||
--card: #ffffff;
|
||||
--sidebar-w: 250px;
|
||||
--header-h: 60px;
|
||||
--radius: 12px;
|
||||
--shadow: 0 2px 12px rgba(0,91,170,.08);
|
||||
}
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background: var(--bg); color: var(--text); }
|
||||
|
||||
/* ── SIDEBAR ──────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w);
|
||||
background: var(--blue-dark); display: flex; flex-direction: column; z-index: 100;
|
||||
}
|
||||
.sidebar-logo {
|
||||
display: flex; align-items: center; gap: 10px; padding: 20px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.12);
|
||||
text-decoration: none; cursor: pointer; transition: opacity .15s;
|
||||
}
|
||||
.sidebar-logo:hover { opacity: .85; }
|
||||
.dot-kt {
|
||||
background: #60a5fa; color: var(--blue-dark);
|
||||
font-size: .72rem; font-weight: 800; padding: 2px 7px; border-radius: 5px;
|
||||
}
|
||||
.logo-text { color: #fff; font-weight: 700; font-size: 1rem; }
|
||||
.logo-text span { display: block; font-size: .68rem; opacity: .6; font-weight: 400; }
|
||||
|
||||
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
|
||||
.nav-label {
|
||||
padding: 8px 20px 4px; font-size: .63rem; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: rgba(255,255,255,.35);
|
||||
}
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 20px; color: rgba(255,255,255,.75);
|
||||
cursor: pointer; border: none; background: none;
|
||||
width: 100%; text-align: left; font-size: .86rem;
|
||||
text-decoration: none; transition: background .15s, color .15s;
|
||||
}
|
||||
.nav-item:hover { background: rgba(255,255,255,.07); color: #fff; }
|
||||
.nav-item.active { background: rgba(255,255,255,.13); color: #fff; border-left: 3px solid #60a5fa; }
|
||||
.nav-item .badge {
|
||||
margin-left: auto; background: var(--orange); color: #fff;
|
||||
font-size: .62rem; padding: 1px 6px; border-radius: 20px; font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
padding: 14px 20px; border-top: 1px solid rgba(255,255,255,.12);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
background: var(--blue); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: .82rem; flex-shrink: 0;
|
||||
}
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.user-name { color: #fff; font-size: .83rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-cdn { color: rgba(255,255,255,.45); font-size: .7rem; font-family: monospace; }
|
||||
.logout-btn {
|
||||
color: rgba(255,255,255,.4); cursor: pointer; font-size: .8rem;
|
||||
background: none; border: none; padding: 0; flex-shrink: 0;
|
||||
}
|
||||
.logout-btn:hover { color: #fff; }
|
||||
|
||||
/* ── HEADER ───────────────────────────────────────────────── */
|
||||
.header {
|
||||
position: fixed; top: 0; left: var(--sidebar-w); right: 0;
|
||||
height: var(--header-h); background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 28px; gap: 16px; z-index: 90;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.05);
|
||||
}
|
||||
.header-title { font-size: 1rem; font-weight: 700; flex: 1; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.btn-primary {
|
||||
padding: 8px 18px; border-radius: 8px; background: var(--blue); color: #fff;
|
||||
border: none; cursor: pointer; font-size: .84rem; font-weight: 700;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--blue-dark); }
|
||||
|
||||
/* ── MAIN ─────────────────────────────────────────────────── */
|
||||
.main {
|
||||
margin-left: var(--sidebar-w); margin-top: var(--header-h);
|
||||
padding: 28px; min-height: calc(100vh - var(--header-h));
|
||||
}
|
||||
.page { display: none; animation: fadeIn .18s; }
|
||||
.page.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:none; } }
|
||||
|
||||
/* ── SECTION TITLE ────────────────────────────────────────── */
|
||||
.sec-title {
|
||||
font-size: .72rem; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--text-muted); font-weight: 600; margin-bottom: 14px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.sec-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* ── KPI ──────────────────────────────────────────────────── */
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap: 14px; margin-bottom: 28px; }
|
||||
.kpi {
|
||||
background: var(--card); border-radius: var(--radius); padding: 18px 20px;
|
||||
box-shadow: var(--shadow); display: flex; align-items: center; gap: 14px;
|
||||
}
|
||||
.kpi-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; flex-shrink: 0; }
|
||||
.kpi-icon.blue { background: var(--blue-light); }
|
||||
.kpi-icon.green { background: var(--green-bg); }
|
||||
.kpi-icon.orange { background: var(--orange-bg); }
|
||||
.kpi-val { font-size: 1.5rem; font-weight: 800; line-height: 1; }
|
||||
.kpi-label { font-size: .74rem; color: var(--text-muted); margin-top: 3px; }
|
||||
|
||||
/* ── DOMAIN CARDS ─────────────────────────────────────────── */
|
||||
.domains-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px,1fr)); gap: 18px; margin-bottom: 28px; }
|
||||
.domain-card {
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); overflow: hidden; transition: box-shadow .2s;
|
||||
}
|
||||
.domain-card:hover { box-shadow: 0 4px 20px rgba(0,91,170,.13); }
|
||||
.domain-card__head {
|
||||
padding: 16px 18px 12px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
}
|
||||
.domain-icon {
|
||||
width: 42px; height: 42px; border-radius: 10px;
|
||||
background: var(--blue-light); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 1.1rem; flex-shrink: 0;
|
||||
}
|
||||
.domain-name { font-size: 1rem; font-weight: 700; line-height: 1.2; }
|
||||
.domain-name .kt { color: var(--blue); }
|
||||
.domain-type { font-size: .72rem; color: var(--text-muted); margin-top: 2px; }
|
||||
.status-pill {
|
||||
margin-left: auto; padding: 3px 10px; border-radius: 20px;
|
||||
font-size: .7rem; font-weight: 700; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.status-pill.active { background: var(--green-bg); color: var(--green); }
|
||||
.status-pill.pending { background: var(--orange-bg); color: var(--orange); }
|
||||
.status-pill.conflict { background: var(--red-bg); color: var(--red); }
|
||||
|
||||
.domain-card__body { padding: 12px 18px; }
|
||||
.info-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: .82rem; }
|
||||
.info-row__label { color: var(--text-muted); }
|
||||
.info-row__val { font-weight: 600; font-family: monospace; font-size: .8rem; }
|
||||
|
||||
.domain-card__actions { display: flex; gap: 8px; padding: 0 18px 16px; }
|
||||
.btn-sm {
|
||||
flex: 1; padding: 7px 10px; border-radius: 7px; font-size: .78rem;
|
||||
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--bg); color: var(--text); transition: background .15s, border-color .15s;
|
||||
}
|
||||
.btn-sm:hover { background: var(--blue-light); border-color: var(--blue); color: var(--blue); }
|
||||
.btn-sm.danger:hover { background: var(--red-bg); border-color: var(--red); color: var(--red); }
|
||||
|
||||
/* ── REGISTER FORM ────────────────────────────────────────── */
|
||||
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 24px; }
|
||||
.card-title { font-size: .95rem; font-weight: 700; margin-bottom: 18px; }
|
||||
|
||||
.reg-search-row {
|
||||
display: flex; gap: 0; margin-bottom: 14px;
|
||||
border: 1.5px solid var(--border); border-radius: 9px; overflow: hidden;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.reg-search-row:focus-within {
|
||||
border-color: var(--blue); box-shadow: 0 0 0 3px rgba(0,91,170,.1);
|
||||
}
|
||||
.reg-suffix {
|
||||
padding: 0 14px; background: var(--bg); color: var(--blue);
|
||||
font-size: .92rem; font-weight: 800; display: flex; align-items: center;
|
||||
border-left: 1px solid var(--border); white-space: nowrap; flex-shrink: 0;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
.reg-input {
|
||||
flex: 1; padding: 11px 12px; border: none; outline: none;
|
||||
font-size: .9rem; background: #fff; min-width: 0; color: var(--text);
|
||||
}
|
||||
.reg-check-btn {
|
||||
padding: 0 20px; background: var(--blue); color: #fff;
|
||||
border: none; cursor: pointer; font-size: .84rem; font-weight: 700;
|
||||
transition: background .15s; flex-shrink: 0;
|
||||
}
|
||||
.reg-check-btn:hover { background: var(--blue-dark); }
|
||||
|
||||
.reg-result {
|
||||
padding: 12px 14px; border-radius: 8px; font-size: .85rem;
|
||||
margin-bottom: 14px; display: none;
|
||||
}
|
||||
.reg-result.free { background: var(--green-bg); color: var(--green); display: block; }
|
||||
.reg-result.taken { background: var(--red-bg); color: var(--red); display: block; }
|
||||
.reg-result.mine { background: var(--orange-bg); color: var(--orange); display: block; }
|
||||
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: .76rem; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; padding: 10px 12px; border: 1.5px solid var(--border);
|
||||
border-radius: 8px; font-size: .88rem; color: var(--text);
|
||||
background: var(--bg); outline: none; transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--blue); box-shadow: 0 0 0 3px rgba(0,91,170,.1); background: #fff;
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 70px; }
|
||||
.field-hint { font-size: .75rem; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
/* ── CHANGE DOMAIN MODAL ──────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||
display: none; align-items: center; justify-content: center; z-index: 200;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--card); border-radius: 16px; width: 440px; max-width: 94vw;
|
||||
padding: 28px; box-shadow: 0 12px 48px rgba(0,0,0,.2);
|
||||
animation: popIn .2s;
|
||||
}
|
||||
@keyframes popIn { from { opacity:0; transform:scale(.94) translateY(8px); } to { opacity:1; transform:none; } }
|
||||
.modal-title { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.modal-sub { font-size: .82rem; color: var(--text-muted); margin-bottom: 20px; }
|
||||
.modal-actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.btn-cancel {
|
||||
flex: 1; padding: 10px; border-radius: 8px; font-size: .85rem;
|
||||
font-weight: 600; border: 1px solid var(--border); background: var(--bg);
|
||||
color: var(--text); cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--border); }
|
||||
.btn-confirm {
|
||||
flex: 1; padding: 10px; border-radius: 8px; font-size: .85rem;
|
||||
font-weight: 700; background: var(--blue); color: #fff; border: none; cursor: pointer;
|
||||
}
|
||||
.btn-confirm:hover { background: var(--blue-dark); }
|
||||
|
||||
/* ── TOAST ────────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
background: var(--text); color: #fff; padding: 11px 18px;
|
||||
border-radius: 9px; font-size: .84rem; font-weight: 500;
|
||||
transform: translateY(60px); opacity: 0; transition: transform .25s, opacity .25s; z-index: 300;
|
||||
}
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.green { background: var(--green); }
|
||||
.toast.red { background: var(--red); }
|
||||
|
||||
/* ── PROFILE ──────────────────────────────────────────────── */
|
||||
.profile-grid { display: grid; grid-template-columns: 200px 1fr; gap: 20px; }
|
||||
.profile-aside {
|
||||
background: var(--card); border-radius: var(--radius); padding: 24px 16px;
|
||||
box-shadow: var(--shadow); text-align: center; display: flex;
|
||||
flex-direction: column; align-items: center; gap: 10px;
|
||||
}
|
||||
.big-avatar {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
background: var(--blue); color: #fff; font-size: 1.6rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
@media (max-width: 700px) { .profile-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── SIDEBAR ──────────────────────────────────────────────────── -->
|
||||
<aside class="sidebar">
|
||||
<a href="index.html" class="sidebar-logo">
|
||||
<span class="dot-kt">.kt</span>
|
||||
<div class="logo-text">domain.kt<span>Личный кабинет</span></div>
|
||||
</a>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-label">Домены</div>
|
||||
<a href="#" class="nav-item active" data-page="my-domains">
|
||||
🌐 Мои домены <span class="badge" id="badge-count">3</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="register-domain">
|
||||
➕ Зарегистрировать домен
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="history">
|
||||
🕐 История заявок
|
||||
</a>
|
||||
<div class="nav-label">Аккаунт</div>
|
||||
<a href="#" class="nav-item" data-page="profile">
|
||||
👤 Профиль
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="settings">
|
||||
⚙️ Настройки
|
||||
</a>
|
||||
<a href="admin.html" class="nav-item" id="nav-admin-link" style="display:none;">
|
||||
🛡️ Админ-панель
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-user">
|
||||
<div class="avatar" id="sidebar-avatar">АТ</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="sidebar-name">Аскар Тулеев</div>
|
||||
<div class="user-cdn" id="sidebar-cdn">tuleev.a</div>
|
||||
</div>
|
||||
<button class="logout-btn" onclick="window.location='login.html'" title="Выйти">⏏</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── HEADER ────────────────────────────────────────────────────── -->
|
||||
<header class="header">
|
||||
<div class="header-title" id="header-title">Мои домены</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-primary" onclick="showPage('register-domain')">+ Новый домен</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── MAIN ──────────────────────────────────────────────────────── -->
|
||||
<main class="main">
|
||||
|
||||
<!-- ════ PAGE: my-domains ════ -->
|
||||
<div class="page active" id="page-my-domains">
|
||||
|
||||
<div class="kpi-row">
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon blue">🌐</div>
|
||||
<div><div class="kpi-val" id="kpi-total">–</div><div class="kpi-label">Доменов всего</div></div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon green">✅</div>
|
||||
<div><div class="kpi-val" id="kpi-active">–</div><div class="kpi-label">Активных</div></div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon orange">⏳</div>
|
||||
<div><div class="kpi-val" id="kpi-pending">–</div><div class="kpi-label">На проверке</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title">🌐 Зарегистрированные домены <button class="btn-sm" style="margin-left:10px;" onclick="window.reloadDomains && window.reloadDomains()">⟳ Обновить</button></div>
|
||||
<div class="domains-grid" id="domains-grid">
|
||||
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
</div><!-- /page-my-domains -->
|
||||
|
||||
<!-- ════ PAGE: register-domain ════ -->
|
||||
<div class="page" id="page-register-domain">
|
||||
|
||||
<div style="max-width:580px;">
|
||||
<div class="card">
|
||||
<div class="card-title">Зарегистрировать новый домен</div>
|
||||
|
||||
<!-- Domain search -->
|
||||
<div class="field">
|
||||
<label>Желаемое имя домена</label>
|
||||
<div class="reg-search-row">
|
||||
<input class="reg-input" id="reg-name-input" type="text"
|
||||
placeholder="название-проекта" autocomplete="off"
|
||||
oninput="clearRegResult()" />
|
||||
<span class="reg-suffix">.kt</span>
|
||||
<button class="reg-check-btn" onclick="checkRegDomain()">Проверить</button>
|
||||
</div>
|
||||
<div class="field-hint">Латинские буквы, цифры и дефис. Не менее 3 символов.</div>
|
||||
</div>
|
||||
|
||||
<div class="reg-result" id="reg-result"></div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="field">
|
||||
<label>Тип домена</label>
|
||||
<select id="reg-type" onchange="updateTypeHint()">
|
||||
<option value="personal">Персональный (по CDN — авто)</option>
|
||||
<option value="project" selected>Проектный (требует одобрения IT)</option>
|
||||
</select>
|
||||
<div class="field-hint" id="type-hint" style="color:var(--orange);">
|
||||
Проектные домены проходят проверку IT-администратором (1–2 рабочих дня).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purpose -->
|
||||
<div class="field">
|
||||
<label>Назначение / описание</label>
|
||||
<textarea id="reg-purpose" placeholder="Кратко опишите для чего нужен домен (для IT-администратора)"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- DNS type -->
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Тип DNS-записи</label>
|
||||
<select id="reg-dns-type">
|
||||
<option>A</option>
|
||||
<option>CNAME</option>
|
||||
<option>MX</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Целевой IP / хост</label>
|
||||
<input type="text" id="reg-dns-target" placeholder="10.0.0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" style="width:100%;padding:11px;" onclick="submitRegistration()">
|
||||
Подать заявку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /page-register-domain -->
|
||||
|
||||
<!-- ════ PAGE: history ════ -->
|
||||
<div class="page" id="page-history">
|
||||
<div class="card">
|
||||
<div class="card-title">История заявок</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.83rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid var(--border);">
|
||||
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Домен</th>
|
||||
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Тип</th>
|
||||
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Дата</th>
|
||||
<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-size:.71rem;text-transform:uppercase;">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody"><!-- populated by JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- /page-history -->
|
||||
|
||||
<!-- ════ PAGE: profile ════ -->
|
||||
<div class="page" id="page-profile">
|
||||
<div class="profile-grid">
|
||||
<div class="profile-aside">
|
||||
<div class="big-avatar" id="profile-avatar">АТ</div>
|
||||
<strong id="profile-fullname">Аскар Тулеев</strong>
|
||||
<span style="font-size:.76rem;color:var(--text-muted);" id="profile-cdn-display">CDN: tuleev.a</span>
|
||||
<span class="status-pill active" style="margin-top:4px;">Активный сотрудник</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-title">Личные данные</div>
|
||||
<div class="form-row">
|
||||
<div class="field"><label>Имя</label><input type="text" id="p-name" value="Аскар" /></div>
|
||||
<div class="field"><label>Фамилия</label><input type="text" id="p-surname" value="Тулеев" /></div>
|
||||
<div class="field"><label>CDN</label><input type="text" id="p-cdn" value="tuleev.a" readonly style="background:#f9f9f9;color:var(--text-muted);" /></div>
|
||||
<div class="field"><label>Корп. email</label><input type="email" id="p-email" value="tuleev.a@telecom.kz" /></div>
|
||||
<div class="field" style="grid-column:1/-1;"><label>Отдел</label><input type="text" id="p-dept" value="Департамент цифровых продуктов" /></div>
|
||||
</div>
|
||||
<button class="btn-primary" style="margin-top:6px;" onclick="toast('Данные сохранены','green')">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /page-profile -->
|
||||
|
||||
<!-- ════ PAGE: settings ════ -->
|
||||
<div class="page" id="page-settings">
|
||||
<div class="card" style="max-width:540px;">
|
||||
<div class="card-title">Настройки уведомлений</div>
|
||||
<div style="display:flex;flex-direction:column;gap:0;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||
<div><strong style="font-size:.88rem;">Email при смене статуса заявки</strong><br><span style="font-size:.76rem;color:var(--text-muted);">Одобрено / Отклонено / Конфликт</span></div>
|
||||
<label style="position:relative;width:40px;height:22px;flex-shrink:0;">
|
||||
<input type="checkbox" checked style="opacity:0;width:0;height:0;">
|
||||
<span style="position:absolute;inset:0;background:var(--blue);border-radius:22px;cursor:pointer;transition:background .2s;"></span>
|
||||
<span style="position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:3px;left:3px;transition:transform .2s;transform:translateX(18px);box-shadow:0 1px 3px rgba(0,0,0,.2);"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||
<div><strong style="font-size:.88rem;">Напоминание о продлении</strong><br><span style="font-size:.76rem;color:var(--text-muted);">За 7 дней до истечения срока домена</span></div>
|
||||
<label style="position:relative;width:40px;height:22px;flex-shrink:0;">
|
||||
<input type="checkbox" checked style="opacity:0;width:0;height:0;">
|
||||
<span style="position:absolute;inset:0;background:var(--blue);border-radius:22px;cursor:pointer;"></span>
|
||||
<span style="position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:3px;left:3px;transform:translateX(18px);box-shadow:0 1px 3px rgba(0,0,0,.2);"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;">
|
||||
<div><strong style="font-size:.88rem;">Push при конфликте домена</strong><br><span style="font-size:.76rem;color:var(--text-muted);">Если IT заблокировал или переназначил</span></div>
|
||||
<label style="position:relative;width:40px;height:22px;flex-shrink:0;">
|
||||
<input type="checkbox" style="opacity:0;width:0;height:0;">
|
||||
<span style="position:absolute;inset:0;background:var(--border);border-radius:22px;cursor:pointer;"></span>
|
||||
<span style="position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:3px;left:3px;box-shadow:0 1px 3px rgba(0,0,0,.2);"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /page-settings -->
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ── CHANGE DOMAIN MODAL ────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-change">
|
||||
<div class="modal">
|
||||
<div class="modal-title">Сменить имя домена</div>
|
||||
<div class="modal-sub">Текущий домен: <strong id="modal-current-domain"></strong></div>
|
||||
<div class="field">
|
||||
<label>Новое имя домена</label>
|
||||
<div class="reg-search-row" style="margin-bottom:8px;">
|
||||
<input class="reg-input" id="modal-new-name" type="text" placeholder="новое-имя" oninput="clearModalResult()" />
|
||||
<span class="reg-suffix">.kt</span>
|
||||
<button class="reg-check-btn" onclick="checkModalDomain()">Проверить</button>
|
||||
</div>
|
||||
<div class="reg-result" id="modal-result"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeModal()">Отмена</button>
|
||||
<button class="btn-confirm" onclick="applyChange()">Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TOAST ──────────────────────────────────────────────────────── -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script type="module">
|
||||
import { api, requireAuth, logout, formatDate } from './app.js';
|
||||
|
||||
const user = requireAuth();
|
||||
if (!user) throw new Error('redirecting');
|
||||
if (user.role === 'admin') {
|
||||
const link = document.getElementById('nav-admin-link');
|
||||
if (link) link.style.display = '';
|
||||
}
|
||||
|
||||
const PAGE_TITLES = {
|
||||
'my-domains': 'Мои домены',
|
||||
'register-domain': 'Зарегистрировать домен',
|
||||
'history': 'История заявок',
|
||||
'profile': 'Профиль',
|
||||
'settings': 'Настройки',
|
||||
};
|
||||
|
||||
function showPage(id) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
const pg = document.getElementById('page-' + id);
|
||||
if (pg) { pg.classList.add('active'); window.scrollTo({ top:0, behavior:'smooth' }); }
|
||||
const ni = document.querySelector(`.nav-item[data-page="${id}"]`);
|
||||
if (ni) ni.classList.add('active');
|
||||
document.getElementById('header-title').textContent = PAGE_TITLES[id] || id;
|
||||
if (id === 'my-domains' || id === 'history') reloadDomains();
|
||||
if (id === 'profile') loadProfile();
|
||||
}
|
||||
window.showPage = showPage;
|
||||
|
||||
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||
item.addEventListener('click', e => { e.preventDefault(); showPage(item.dataset.page); });
|
||||
});
|
||||
|
||||
// hook logout
|
||||
const logoutBtn = document.querySelector('.logout-btn');
|
||||
if (logoutBtn) logoutBtn.onclick = () => logout();
|
||||
|
||||
// ── Sidebar ─────────────────────────────────────────────────────
|
||||
const cdn = user.cdn;
|
||||
const initials = (cdn[0] + (cdn.split('.')[1] || '')[0] || '').toUpperCase();
|
||||
document.getElementById('sidebar-cdn').textContent = cdn;
|
||||
document.getElementById('sidebar-avatar').textContent = initials;
|
||||
document.getElementById('profile-cdn-display').textContent = 'CDN: ' + cdn;
|
||||
const pCdn = document.getElementById('p-cdn'); if (pCdn) pCdn.value = cdn;
|
||||
|
||||
// ── Render user domains ─────────────────────────────────────────
|
||||
const STATUS_LABEL = { active:'Активен', pending:'На проверке', conflict:'Конфликт', inactive:'Отклонён' };
|
||||
const TYPE_LABEL = { personal:'Персональный · автоматически по CDN', project:'Проектный · по заявке' };
|
||||
|
||||
async function reloadDomains() {
|
||||
const grid = document.getElementById('domains-grid');
|
||||
grid.innerHTML = '<div style="padding:20px;color:var(--text-muted);">Загрузка...</div>';
|
||||
try {
|
||||
const list = await api('/domains');
|
||||
renderKpi(list);
|
||||
renderGrid(list);
|
||||
renderHistory(list);
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div style="padding:20px;color:var(--red,#c33);">Ошибка: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
window.reloadDomains = reloadDomains;
|
||||
|
||||
function renderKpi(list) {
|
||||
const total = list.length;
|
||||
const active = list.filter(d => d.status === 'active').length;
|
||||
const pending = list.filter(d => d.status === 'pending').length;
|
||||
document.getElementById('kpi-total').textContent = total;
|
||||
document.getElementById('kpi-active').textContent = active;
|
||||
document.getElementById('kpi-pending').textContent = pending;
|
||||
}
|
||||
|
||||
function renderGrid(list) {
|
||||
const grid = document.getElementById('domains-grid');
|
||||
if (!list.length) {
|
||||
grid.innerHTML = `<div style="padding:20px;color:var(--text-muted);">Пока нет доменов. Открой "Зарегистрировать домен".</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list.map(d => {
|
||||
const icon = d.type === 'personal' ? '🏠' : (d.status === 'pending' ? '📋' : '🗂️');
|
||||
const statusLabel = STATUS_LABEL[d.status] || d.status;
|
||||
const typeLabel = TYPE_LABEL[d.type] || d.type;
|
||||
const ip = d.dns_record || '—';
|
||||
const created = formatDate(d.created_at);
|
||||
return `
|
||||
<div class="domain-card">
|
||||
<div class="domain-card__head">
|
||||
<div class="domain-icon">${icon}</div>
|
||||
<div>
|
||||
<div class="domain-name">${escapeHtml(d.name)}<span class="kt">.kt</span></div>
|
||||
<div class="domain-type">${typeLabel}</div>
|
||||
</div>
|
||||
<span class="status-pill ${d.status}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="domain-card__body">
|
||||
<div class="info-row"><span class="info-row__label">Полный адрес</span><span class="info-row__val">${escapeHtml(d.fqdn)}</span></div>
|
||||
<div class="info-row"><span class="info-row__label">CDN владельца</span><span class="info-row__val">${escapeHtml(cdn)}</span></div>
|
||||
<div class="info-row"><span class="info-row__label">Зарегистрирован</span><span class="info-row__val">${created}</span></div>
|
||||
<div class="info-row"><span class="info-row__label">DNS-запись</span><span class="info-row__val">A ${escapeHtml(ip)}</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderHistory(list) {
|
||||
const tbody = document.getElementById('history-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = list.map(d => `
|
||||
<tr style="border-bottom:1px solid var(--border);">
|
||||
<td style="padding:11px 12px;font-weight:600;font-family:monospace;">${escapeHtml(d.fqdn)}</td>
|
||||
<td style="padding:11px 12px;">${d.type === 'personal' ? 'Персональный' : 'Проектный'}</td>
|
||||
<td style="padding:11px 12px;color:var(--text-muted);">${formatDate(d.created_at)}</td>
|
||||
<td style="padding:11px 12px;"><span class="status-pill ${d.status}">${STATUS_LABEL[d.status] || d.status}</span></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
// ── Domain check (register page) ────────────────────────────────
|
||||
let lastCheck = null;
|
||||
window.checkRegDomain = async function() {
|
||||
const raw = document.getElementById('reg-name-input').value.trim().toLowerCase().replace(/[^a-z0-9-]/g,'');
|
||||
if (!raw || raw.length < 2) { toast('Введите имя домена (минимум 2 символа)'); return; }
|
||||
const el = document.getElementById('reg-result');
|
||||
el.className = 'reg-result'; el.textContent = 'Проверяю...';
|
||||
try {
|
||||
const r = await api('/domains/check?name=' + encodeURIComponent(raw));
|
||||
lastCheck = r;
|
||||
if (r.available) {
|
||||
el.className = 'reg-result free';
|
||||
el.textContent = `✅ ${raw}.kt — свободен! Можно регистрировать.`;
|
||||
} else {
|
||||
el.className = 'reg-result taken';
|
||||
const hint = r.reason === 'taken' && r.suggestions?.length
|
||||
? ` Предложения: ${r.suggestions.join(', ')}`
|
||||
: (r.reason ? ` (${r.reason})` : '');
|
||||
el.textContent = `❌ ${raw}.kt — недоступен.${hint}`;
|
||||
}
|
||||
} catch (e) {
|
||||
el.className = 'reg-result taken';
|
||||
el.textContent = `Ошибка проверки: ${e.message}`;
|
||||
}
|
||||
};
|
||||
window.clearRegResult = function() { document.getElementById('reg-result').className = 'reg-result'; };
|
||||
|
||||
window.updateTypeHint = function() {
|
||||
const type = document.getElementById('reg-type').value;
|
||||
const hint = document.getElementById('type-hint');
|
||||
if (type === 'project') {
|
||||
hint.textContent = 'Проектные домены проходят проверку IT-администратором.';
|
||||
hint.style.color = 'var(--orange)';
|
||||
} else {
|
||||
hint.textContent = 'Персональный домен формируется автоматически из вашего CDN.';
|
||||
hint.style.color = 'var(--blue)';
|
||||
}
|
||||
};
|
||||
|
||||
window.submitRegistration = async function() {
|
||||
const type = document.getElementById('reg-type').value;
|
||||
const name = document.getElementById('reg-name-input').value.trim().toLowerCase();
|
||||
const purpose = document.getElementById('reg-purpose').value.trim();
|
||||
const dnsTarget = document.getElementById('reg-dns-target').value.trim();
|
||||
|
||||
if (type === 'project' && !name) { toast('Введите имя домена'); return; }
|
||||
try {
|
||||
const r = await api('/domains/register', {
|
||||
method: 'POST',
|
||||
body: { type, name: type === 'project' ? name : undefined, purpose, dns: dnsTarget },
|
||||
});
|
||||
const msg = type === 'personal'
|
||||
? 'Процесс регистрации запущен в Zorro. Домен появится через несколько секунд.'
|
||||
: `Заявка на ${name}.kt отправлена IT-администратору.`;
|
||||
toast(msg, 'green');
|
||||
document.getElementById('reg-name-input').value = '';
|
||||
document.getElementById('reg-purpose').value = '';
|
||||
document.getElementById('reg-dns-target').value = '';
|
||||
window.clearRegResult();
|
||||
setTimeout(() => showPage('my-domains'), 1500);
|
||||
// reload a couple of times to catch the background pipeline
|
||||
setTimeout(reloadDomains, 4000);
|
||||
setTimeout(reloadDomains, 9000);
|
||||
} catch (e) {
|
||||
toast(`Ошибка: ${e.message}`, 'red');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Profile ──────────────────────────────────────────────────────
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const me = await api('/auth/me');
|
||||
const name = document.getElementById('p-name'); if (name) name.value = (me.name || '').split(' ')[0] || '';
|
||||
const surname = document.getElementById('p-surname'); if (surname) surname.value = (me.name || '').split(' ').slice(1).join(' ') || '';
|
||||
const email = document.getElementById('p-email'); if (email) email.value = me.email || '';
|
||||
const dept = document.getElementById('p-dept'); if (dept) dept.value = me.department || '';
|
||||
const ff = document.getElementById('profile-fullname'); if (ff) ff.textContent = me.name;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Change / Delete — not implemented in MVP ────────────────────
|
||||
window.openChangeDomain = (d) => toast(`Смена имени пока не реализована для ${d}`, 'orange');
|
||||
window.closeModal = () => document.getElementById('modal-change').classList.remove('open');
|
||||
window.checkModalDomain = () => {};
|
||||
window.clearModalResult = () => {};
|
||||
window.applyChange = () => window.closeModal();
|
||||
window.confirmDelete = (d) => toast(`Удаление пока не реализовано для ${d}`, 'orange');
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────
|
||||
let toastTimer;
|
||||
window.toast = function(msg, type = '') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show' + (type ? ' ' + type : '');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { t.className = 'toast'; }, 3500);
|
||||
};
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────
|
||||
reloadDomains();
|
||||
loadProfile();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
455
domain-registration.bpmn
Normal file
455
domain-registration.bpmn
Normal file
@ -0,0 +1,455 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions
|
||||
xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:zeebe="http://camunda.org/schema/zeebe/1.0"
|
||||
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||
id="Definitions_KT_Domain"
|
||||
targetNamespace="http://kt.telecom/bpmn"
|
||||
exporter="Camunda Modeler"
|
||||
exporterVersion="5.x">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
PROCESS — Domain Registration (MVP)
|
||||
ID: kt_domain_registration
|
||||
Desc: Регистрация домена второго уровня в зоне .kt.
|
||||
CDN → имя домена → проверка → авто/ручное одобрение → PowerDNS → email
|
||||
════════════════════════════════════════════════════════════════════ -->
|
||||
<process
|
||||
id="kt_domain_registration"
|
||||
name="Регистрация домена .kt"
|
||||
isExecutable="true">
|
||||
|
||||
<extensionElements>
|
||||
<zeebe:userTaskForm id="form_admin_review">
|
||||
{
|
||||
"schemaVersion": 10,
|
||||
"id": "admin_review_form",
|
||||
"type": "default",
|
||||
"components": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "### Заявка на регистрацию домена\nПроверьте данные и примите решение."
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"key": "adminDecision",
|
||||
"label": "Решение",
|
||||
"values": [
|
||||
{ "label": "Одобрить", "value": "APPROVE" },
|
||||
{ "label": "Отклонить", "value": "REJECT" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "textfield",
|
||||
"key": "adminComment",
|
||||
"label": "Комментарий",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
</zeebe:userTaskForm>
|
||||
</extensionElements>
|
||||
|
||||
<!-- START -->
|
||||
<startEvent id="start" name="Сотрудник подал заявку">
|
||||
<outgoing>flow_start_to_validate</outgoing>
|
||||
</startEvent>
|
||||
|
||||
<!-- TASK 1: Validate CDN and build domain name
|
||||
Input: employeeCdn (e.g. "tuleev.a"), domainType ("personal"|"project"), requestedName
|
||||
Output: isValid (bool), finalDomainName (e.g. "tuleev" or "devops-team")
|
||||
Logic:
|
||||
personal → берём часть до точки в CDN: "tuleev.a" → "tuleev"
|
||||
project → используем requestedName как есть
|
||||
Зона .kt добавляется только в шаге pdns.createRecord -->
|
||||
<serviceTask id="task_validate_and_build"
|
||||
name="Проверить CDN и сформировать имя домена">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="employee.validateAndBuild" retries="3" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=employeeCdn" target="cdn" />
|
||||
<zeebe:input source="=domainType" target="domainType" />
|
||||
<zeebe:input source="=requestedName" target="requestedName" />
|
||||
<zeebe:output source="=isValid" target="isValid" />
|
||||
<zeebe:output source="=finalDomainName" target="finalDomainName" />
|
||||
<zeebe:output source="=employeeFullName" target="employeeFullName" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_start_to_validate</incoming>
|
||||
<outgoing>flow_validate_to_gw_valid</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<!-- GW1: CDN valid? -->
|
||||
<exclusiveGateway id="gw_valid" name="CDN корректен?">
|
||||
<incoming>flow_validate_to_gw_valid</incoming>
|
||||
<outgoing>flow_valid_yes</outgoing>
|
||||
<outgoing>flow_valid_no</outgoing>
|
||||
</exclusiveGateway>
|
||||
|
||||
<endEvent id="end_invalid" name="CDN не найден. Заявка отклонена.">
|
||||
<incoming>flow_valid_no</incoming>
|
||||
</endEvent>
|
||||
|
||||
<!-- TASK 2: Check domain availability in registry -->
|
||||
<serviceTask id="task_check_availability"
|
||||
name="Проверить доступность домена в реестре .kt">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="domain.checkAvailability" retries="3" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
<zeebe:output source="=isAvailable" target="isAvailable" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_valid_yes</incoming>
|
||||
<outgoing>flow_check_to_gw_avail</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<!-- GW2: Available? -->
|
||||
<exclusiveGateway id="gw_available" name="Имя свободно?">
|
||||
<incoming>flow_check_to_gw_avail</incoming>
|
||||
<outgoing>flow_avail_yes</outgoing>
|
||||
<outgoing>flow_avail_no</outgoing>
|
||||
</exclusiveGateway>
|
||||
|
||||
<!-- Not available → notify employee → end -->
|
||||
<serviceTask id="task_notify_unavailable"
|
||||
name="Уведомить: имя занято">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="notification.sendUnavailable" retries="2" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=employeeEmail" target="email" />
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_avail_no</incoming>
|
||||
<outgoing>flow_unavail_to_end</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<endEvent id="end_unavailable" name="Имя занято. Сотрудник уведомлён.">
|
||||
<incoming>flow_unavail_to_end</incoming>
|
||||
</endEvent>
|
||||
|
||||
<!-- GW3: Domain type — personal (auto) or project (admin review) -->
|
||||
<exclusiveGateway id="gw_type" name="Тип домена?">
|
||||
<incoming>flow_avail_yes</incoming>
|
||||
<outgoing>flow_type_personal</outgoing>
|
||||
<outgoing>flow_type_project</outgoing>
|
||||
</exclusiveGateway>
|
||||
|
||||
<!-- TASK: Auto-approve personal domain (save to registry) -->
|
||||
<serviceTask id="task_auto_approve"
|
||||
name="Авто-одобрение: зарегистрировать персональный домен">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="domain.autoApprove" retries="2" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
<zeebe:input source="=employeeCdn" target="cdn" />
|
||||
<zeebe:output source="=domainId" target="domainId" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_type_personal</incoming>
|
||||
<outgoing>flow_auto_to_dns</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<!-- USER TASK: IT admin reviews project domain request -->
|
||||
<userTask id="utask_admin_review"
|
||||
name="IT-администратор рассматривает заявку на проектный домен">
|
||||
<extensionElements>
|
||||
<zeebe:formDefinition formKey="camunda-forms:bpmn:form_admin_review" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
<zeebe:input source="=employeeCdn" target="cdn" />
|
||||
<zeebe:input source="=employeeFullName" target="fullName" />
|
||||
<zeebe:input source="=purpose" target="purpose" />
|
||||
<zeebe:output source="=adminDecision" target="adminDecision" />
|
||||
<zeebe:output source="=adminComment" target="adminComment" />
|
||||
</zeebe:ioMapping>
|
||||
<zeebe:assignmentDefinition candidateGroups="kt_domain_admins" />
|
||||
</extensionElements>
|
||||
<incoming>flow_type_project</incoming>
|
||||
<outgoing>flow_admin_to_gw</outgoing>
|
||||
</userTask>
|
||||
|
||||
<!-- GW4: Admin decision -->
|
||||
<exclusiveGateway id="gw_admin_decision" name="Решение администратора">
|
||||
<incoming>flow_admin_to_gw</incoming>
|
||||
<outgoing>flow_admin_approve</outgoing>
|
||||
<outgoing>flow_admin_reject</outgoing>
|
||||
</exclusiveGateway>
|
||||
|
||||
<!-- Rejected → notify → end -->
|
||||
<serviceTask id="task_notify_rejected"
|
||||
name="Уведомить об отклонении заявки">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="notification.sendRejection" retries="2" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=employeeEmail" target="email" />
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
<zeebe:input source="=adminComment" target="reason" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_admin_reject</incoming>
|
||||
<outgoing>flow_rejected_to_end</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<endEvent id="end_rejected" name="Заявка отклонена. Сотрудник уведомлён.">
|
||||
<incoming>flow_rejected_to_end</incoming>
|
||||
</endEvent>
|
||||
|
||||
<!-- Merge approved paths (personal auto + project approved) -->
|
||||
<exclusiveGateway id="gw_merge" name="Домен одобрен">
|
||||
<incoming>flow_auto_to_dns</incoming>
|
||||
<incoming>flow_admin_approve</incoming>
|
||||
<outgoing>flow_merge_to_dns</outgoing>
|
||||
</exclusiveGateway>
|
||||
|
||||
<!-- TASK: Create DNS record via PowerDNS API
|
||||
Creates an A or CNAME record for finalDomainName.kt in PowerDNS -->
|
||||
<serviceTask id="task_dns"
|
||||
name="Создать DNS-запись в PowerDNS (зона .kt)">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="pdns.createRecord" retries="5" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
<zeebe:input source="=dnsRecordType" target="recordType" />
|
||||
<zeebe:input source="=dnsTarget" target="target" />
|
||||
<zeebe:output source="=dnsStatus" target="dnsStatus" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_merge_to_dns</incoming>
|
||||
<outgoing>flow_dns_to_notify</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<!-- TASK: Notify employee — domain is active -->
|
||||
<serviceTask id="task_notify_active"
|
||||
name="Отправить email: домен активирован">
|
||||
<extensionElements>
|
||||
<zeebe:taskDefinition type="notification.sendActivation" retries="3" />
|
||||
<zeebe:ioMapping>
|
||||
<zeebe:input source="=employeeEmail" target="email" />
|
||||
<zeebe:input source="=employeeFullName" target="fullName" />
|
||||
<zeebe:input source="=finalDomainName" target="domainName" />
|
||||
</zeebe:ioMapping>
|
||||
</extensionElements>
|
||||
<incoming>flow_dns_to_notify</incoming>
|
||||
<outgoing>flow_notify_to_end</outgoing>
|
||||
</serviceTask>
|
||||
|
||||
<!-- END: Domain active -->
|
||||
<endEvent id="end_active" name="Домен активен. Сотрудник уведомлён.">
|
||||
<incoming>flow_notify_to_end</incoming>
|
||||
</endEvent>
|
||||
|
||||
<!-- ════ SEQUENCE FLOWS ════ -->
|
||||
<sequenceFlow id="flow_start_to_validate" sourceRef="start" targetRef="task_validate_and_build" />
|
||||
<sequenceFlow id="flow_validate_to_gw_valid" sourceRef="task_validate_and_build" targetRef="gw_valid" />
|
||||
<sequenceFlow id="flow_valid_yes" name="Да" sourceRef="gw_valid" targetRef="task_check_availability">
|
||||
<conditionExpression xsi:type="tFormalExpression">=isValid = true</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_valid_no" name="Нет" sourceRef="gw_valid" targetRef="end_invalid">
|
||||
<conditionExpression xsi:type="tFormalExpression">=isValid = false</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_check_to_gw_avail" sourceRef="task_check_availability" targetRef="gw_available" />
|
||||
<sequenceFlow id="flow_avail_yes" name="Свободен" sourceRef="gw_available" targetRef="gw_type">
|
||||
<conditionExpression xsi:type="tFormalExpression">=isAvailable = true</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_avail_no" name="Занято" sourceRef="gw_available" targetRef="task_notify_unavailable">
|
||||
<conditionExpression xsi:type="tFormalExpression">=isAvailable = false</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_unavail_to_end" sourceRef="task_notify_unavailable" targetRef="end_unavailable" />
|
||||
<sequenceFlow id="flow_type_personal" name="Персональный" sourceRef="gw_type" targetRef="task_auto_approve">
|
||||
<conditionExpression xsi:type="tFormalExpression">=domainType = "personal"</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_type_project" name="Проектный" sourceRef="gw_type" targetRef="utask_admin_review">
|
||||
<conditionExpression xsi:type="tFormalExpression">=domainType = "project"</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_auto_to_dns" sourceRef="task_auto_approve" targetRef="gw_merge" />
|
||||
<sequenceFlow id="flow_admin_to_gw" sourceRef="utask_admin_review" targetRef="gw_admin_decision" />
|
||||
<sequenceFlow id="flow_admin_approve" name="APPROVE" sourceRef="gw_admin_decision" targetRef="gw_merge">
|
||||
<conditionExpression xsi:type="tFormalExpression">=adminDecision = "APPROVE"</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_admin_reject" name="REJECT" sourceRef="gw_admin_decision" targetRef="task_notify_rejected">
|
||||
<conditionExpression xsi:type="tFormalExpression">=adminDecision = "REJECT"</conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow_rejected_to_end" sourceRef="task_notify_rejected" targetRef="end_rejected" />
|
||||
<sequenceFlow id="flow_merge_to_dns" sourceRef="gw_merge" targetRef="task_dns" />
|
||||
<sequenceFlow id="flow_dns_to_notify" sourceRef="task_dns" targetRef="task_notify_active" />
|
||||
<sequenceFlow id="flow_notify_to_end" sourceRef="task_notify_active" targetRef="end_active" />
|
||||
|
||||
</process>
|
||||
|
||||
<!-- ════ BPMN DIAGRAM LAYOUT ════ -->
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="kt_domain_registration">
|
||||
|
||||
<!-- Start -->
|
||||
<bpmndi:BPMNShape id="shape_start" bpmnElement="start">
|
||||
<dc:Bounds x="152" y="242" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="126" y="285" width="90" height="27" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task 1: validate and build -->
|
||||
<bpmndi:BPMNShape id="shape_validate" bpmnElement="task_validate_and_build">
|
||||
<dc:Bounds x="240" y="220" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- GW1: valid? -->
|
||||
<bpmndi:BPMNShape id="shape_gw_valid" bpmnElement="gw_valid" isMarkerVisible="true">
|
||||
<dc:Bounds x="455" y="235" width="50" height="50" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="441" y="292" width="78" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- End: invalid CDN -->
|
||||
<bpmndi:BPMNShape id="shape_end_invalid" bpmnElement="end_invalid">
|
||||
<dc:Bounds x="462" y="142" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="436" y="112" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task 2: check availability -->
|
||||
<bpmndi:BPMNShape id="shape_check" bpmnElement="task_check_availability">
|
||||
<dc:Bounds x="560" y="220" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- GW2: available? -->
|
||||
<bpmndi:BPMNShape id="shape_gw_avail" bpmnElement="gw_available" isMarkerVisible="true">
|
||||
<dc:Bounds x="775" y="235" width="50" height="50" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="762" y="292" width="76" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task: notify unavailable -->
|
||||
<bpmndi:BPMNShape id="shape_notify_unavail" bpmnElement="task_notify_unavailable">
|
||||
<dc:Bounds x="750" y="360" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- End: unavailable -->
|
||||
<bpmndi:BPMNShape id="shape_end_unavail" bpmnElement="end_unavailable">
|
||||
<dc:Bounds x="832" y="482" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="806" y="525" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- GW3: domain type -->
|
||||
<bpmndi:BPMNShape id="shape_gw_type" bpmnElement="gw_type" isMarkerVisible="true">
|
||||
<dc:Bounds x="895" y="235" width="50" height="50" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="887" y="292" width="66" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task: auto approve -->
|
||||
<bpmndi:BPMNShape id="shape_auto" bpmnElement="task_auto_approve">
|
||||
<dc:Bounds x="1000" y="140" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- User Task: admin review -->
|
||||
<bpmndi:BPMNShape id="shape_admin_review" bpmnElement="utask_admin_review">
|
||||
<dc:Bounds x="1000" y="300" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- GW4: admin decision -->
|
||||
<bpmndi:BPMNShape id="shape_gw_admin" bpmnElement="gw_admin_decision" isMarkerVisible="true">
|
||||
<dc:Bounds x="1215" y="315" width="50" height="50" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="1195" y="372" width="90" height="27" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task: notify rejected -->
|
||||
<bpmndi:BPMNShape id="shape_notify_rejected" bpmnElement="task_notify_rejected">
|
||||
<dc:Bounds x="1190" y="430" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- End: rejected -->
|
||||
<bpmndi:BPMNShape id="shape_end_rejected" bpmnElement="end_rejected">
|
||||
<dc:Bounds x="1272" y="552" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="1246" y="595" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- GW merge -->
|
||||
<bpmndi:BPMNShape id="shape_gw_merge" bpmnElement="gw_merge" isMarkerVisible="true">
|
||||
<dc:Bounds x="1335" y="235" width="50" height="50" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="1319" y="292" width="82" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task: PowerDNS -->
|
||||
<bpmndi:BPMNShape id="shape_dns" bpmnElement="task_dns">
|
||||
<dc:Bounds x="1450" y="220" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Task: notify active -->
|
||||
<bpmndi:BPMNShape id="shape_notify_active" bpmnElement="task_notify_active">
|
||||
<dc:Bounds x="1670" y="220" width="160" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- End: active -->
|
||||
<bpmndi:BPMNShape id="shape_end_active" bpmnElement="end_active">
|
||||
<dc:Bounds x="1892" y="242" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="1866" y="285" width="88" height="27" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
|
||||
<!-- Edges -->
|
||||
<bpmndi:BPMNEdge id="edge_start_to_validate" bpmnElement="flow_start_to_validate">
|
||||
<di:waypoint x="188" y="260" /><di:waypoint x="240" y="260" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_validate_to_gw" bpmnElement="flow_validate_to_gw_valid">
|
||||
<di:waypoint x="400" y="260" /><di:waypoint x="455" y="260" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_valid_no" bpmnElement="flow_valid_no">
|
||||
<di:waypoint x="480" y="235" /><di:waypoint x="480" y="178" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="487" y="202" width="26" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_valid_yes" bpmnElement="flow_valid_yes">
|
||||
<di:waypoint x="505" y="260" /><di:waypoint x="560" y="260" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="524" y="242" width="18" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_check_to_gw" bpmnElement="flow_check_to_gw_avail">
|
||||
<di:waypoint x="720" y="260" /><di:waypoint x="775" y="260" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_avail_no" bpmnElement="flow_avail_no">
|
||||
<di:waypoint x="800" y="285" /><di:waypoint x="800" y="360" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="808" y="315" width="42" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_unavail_to_end" bpmnElement="flow_unavail_to_end">
|
||||
<di:waypoint x="830" y="440" /><di:waypoint x="850" y="482" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_avail_yes" bpmnElement="flow_avail_yes">
|
||||
<di:waypoint x="825" y="260" /><di:waypoint x="895" y="260" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="843" y="242" width="52" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_type_personal" bpmnElement="flow_type_personal">
|
||||
<di:waypoint x="920" y="235" /><di:waypoint x="920" y="180" /><di:waypoint x="1000" y="180" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="926" y="200" width="66" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_type_project" bpmnElement="flow_type_project">
|
||||
<di:waypoint x="920" y="285" /><di:waypoint x="920" y="340" /><di:waypoint x="1000" y="340" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="926" y="305" width="52" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_auto_to_merge" bpmnElement="flow_auto_to_dns">
|
||||
<di:waypoint x="1160" y="180" /><di:waypoint x="1360" y="180" /><di:waypoint x="1360" y="235" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_admin_to_gw" bpmnElement="flow_admin_to_gw">
|
||||
<di:waypoint x="1160" y="340" /><di:waypoint x="1215" y="340" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_admin_approve" bpmnElement="flow_admin_approve">
|
||||
<di:waypoint x="1240" y="315" /><di:waypoint x="1240" y="260" /><di:waypoint x="1335" y="260" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="1267" y="282" width="46" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_admin_reject" bpmnElement="flow_admin_reject">
|
||||
<di:waypoint x="1240" y="365" /><di:waypoint x="1240" y="430" />
|
||||
<bpmndi:BPMNLabel><dc:Bounds x="1248" y="390" width="34" height="14" /></bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_rejected_to_end" bpmnElement="flow_rejected_to_end">
|
||||
<di:waypoint x="1270" y="510" /><di:waypoint x="1290" y="552" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_merge_to_dns" bpmnElement="flow_merge_to_dns">
|
||||
<di:waypoint x="1385" y="260" /><di:waypoint x="1450" y="260" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_dns_to_notify" bpmnElement="flow_dns_to_notify">
|
||||
<di:waypoint x="1610" y="260" /><di:waypoint x="1670" y="260" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="edge_notify_to_end" bpmnElement="flow_notify_to_end">
|
||||
<di:waypoint x="1830" y="260" /><di:waypoint x="1892" y="260" />
|
||||
</bpmndi:BPMNEdge>
|
||||
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
|
||||
</definitions>
|
||||
539
index.html
Normal file
539
index.html
Normal file
@ -0,0 +1,539 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>domain.kt — Корпоративный регистратор доменов Казахтелеком</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--blue: #005baa;
|
||||
--blue-dark: #003f7a;
|
||||
--blue-light: #e8f0fb;
|
||||
--blue-mid: #1a6fc4;
|
||||
--green: #1ab36e;
|
||||
--green-bg: #e6f9f0;
|
||||
--orange: #f07200;
|
||||
--red: #d93636;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--bg: #f3f6fb;
|
||||
--card: #ffffff;
|
||||
--radius: 14px;
|
||||
--shadow: 0 2px 16px rgba(0,91,170,.10);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── NAV ─────────────────────────────────────────────────── */
|
||||
nav {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(0,63,122,.97);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex; align-items: center;
|
||||
padding: 0 40px; height: 60px; gap: 32px;
|
||||
}
|
||||
.nav-logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
text-decoration: none; color: #fff; font-weight: 700; font-size: 1.1rem;
|
||||
}
|
||||
.nav-logo .dot-kt {
|
||||
background: #60a5fa; color: var(--blue-dark);
|
||||
font-size: .75rem; font-weight: 800;
|
||||
padding: 2px 7px; border-radius: 6px; letter-spacing: .04em;
|
||||
}
|
||||
.nav-links { display: flex; gap: 24px; flex: 1; }
|
||||
.nav-links a {
|
||||
color: rgba(255,255,255,.75); text-decoration: none;
|
||||
font-size: .88rem; transition: color .15s;
|
||||
}
|
||||
.nav-links a:hover { color: #fff; }
|
||||
.nav-actions { display: flex; gap: 10px; }
|
||||
.btn-outline {
|
||||
padding: 7px 18px; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.4);
|
||||
background: none; color: #fff; cursor: pointer;
|
||||
font-size: .85rem; text-decoration: none;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.btn-outline:hover { border-color: #fff; background: rgba(255,255,255,.08); }
|
||||
.btn-primary-sm {
|
||||
padding: 7px 18px; border-radius: 8px;
|
||||
background: #60a5fa; color: var(--blue-dark);
|
||||
border: none; cursor: pointer; font-size: .85rem; font-weight: 700;
|
||||
text-decoration: none; transition: background .15s;
|
||||
}
|
||||
.btn-primary-sm:hover { background: #93c5fd; }
|
||||
|
||||
/* ── HERO ────────────────────────────────────────────────── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(145deg, #002a5c 0%, #005baa 50%, #0077cc 100%);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 100px 24px 60px; text-align: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 100%, rgba(96,165,250,.15) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* decorative grid */
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: rgba(255,255,255,.1);
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
border-radius: 20px; padding: 5px 14px;
|
||||
color: #93c5fd; font-size: .78rem; font-weight: 600;
|
||||
margin-bottom: 28px; position: relative; z-index: 1;
|
||||
}
|
||||
.hero-badge .pulse {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #60a5fa; animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity:1; transform:scale(1); }
|
||||
50% { opacity:.5; transform:scale(1.4); }
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
font-weight: 800; color: #fff; line-height: 1.15;
|
||||
margin-bottom: 18px; position: relative; z-index: 1;
|
||||
}
|
||||
.hero h1 .accent { color: #60a5fa; }
|
||||
.hero p {
|
||||
font-size: 1.05rem; color: rgba(255,255,255,.75);
|
||||
max-width: 520px; line-height: 1.6;
|
||||
margin-bottom: 44px; position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* ── SEARCH BOX ──────────────────────────────────────────── */
|
||||
.search-box {
|
||||
position: relative; z-index: 1;
|
||||
width: 100%; max-width: 640px; margin-bottom: 16px;
|
||||
}
|
||||
.search-row {
|
||||
display: flex; background: #fff;
|
||||
border-radius: 14px; overflow: hidden;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,.25);
|
||||
}
|
||||
.search-suffix {
|
||||
padding: 0 16px; background: var(--bg);
|
||||
display: flex; align-items: center;
|
||||
color: var(--blue); font-size: 1rem; font-weight: 800;
|
||||
border-left: 1px solid var(--border); white-space: nowrap;
|
||||
flex-shrink: 0; letter-spacing: .01em;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1; padding: 18px 16px; border: none; outline: none;
|
||||
font-size: 1rem; color: var(--text); background: #fff;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-input::placeholder { color: #aaa; }
|
||||
.search-btn {
|
||||
padding: 0 28px; background: var(--blue); color: #fff;
|
||||
border: none; cursor: pointer; font-size: .9rem; font-weight: 700;
|
||||
transition: background .15s; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.search-btn:hover { background: var(--blue-dark); }
|
||||
|
||||
.search-hint {
|
||||
color: rgba(255,255,255,.5); font-size: .78rem;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* search result */
|
||||
.search-result {
|
||||
position: relative; z-index: 1;
|
||||
margin-top: 16px; width: 100%; max-width: 640px;
|
||||
display: none;
|
||||
}
|
||||
.search-result.show { display: block; }
|
||||
.result-card {
|
||||
background: #fff; border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||
}
|
||||
.result-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.result-row:last-child { border-bottom: none; }
|
||||
.result-domain { font-weight: 700; font-size: .95rem; flex: 1; }
|
||||
.result-domain span { color: var(--text-muted); font-weight: 400; }
|
||||
.tag-free { background: var(--green-bg); color: var(--green); padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
|
||||
.tag-taken { background: #fdeaea; color: var(--red); padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
|
||||
.btn-claim {
|
||||
padding: 7px 18px; border-radius: 8px; font-size: .82rem;
|
||||
font-weight: 700; background: var(--blue); color: #fff;
|
||||
border: none; cursor: pointer; text-decoration: none;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-claim:hover { background: var(--blue-dark); }
|
||||
|
||||
/* ── STATS ───────────────────────────────────────────────── */
|
||||
.stats {
|
||||
display: flex; gap: 48px; justify-content: center;
|
||||
padding: 32px 24px 0; position: relative; z-index: 1; flex-wrap: wrap;
|
||||
}
|
||||
.stat-item { text-align: center; }
|
||||
.stat-val { font-size: 2rem; font-weight: 800; color: #fff; }
|
||||
.stat-label { font-size: .78rem; color: rgba(255,255,255,.55); margin-top: 2px; }
|
||||
|
||||
/* ── HOW IT WORKS ─────────────────────────────────────────── */
|
||||
.section { padding: 80px 24px; }
|
||||
.section-center { max-width: 1000px; margin: 0 auto; }
|
||||
.section-label {
|
||||
text-align: center; font-size: .75rem; text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--blue); font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section-title {
|
||||
text-align: center; font-size: clamp(1.5rem, 3vw, 2.2rem);
|
||||
font-weight: 800; margin-bottom: 48px; line-height: 1.2;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 28px;
|
||||
}
|
||||
.step-card {
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
padding: 28px 24px; box-shadow: var(--shadow);
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.step-num {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
background: var(--blue-light); color: var(--blue);
|
||||
font-weight: 800; font-size: 1.1rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.step-card h3 { font-size: 1rem; font-weight: 700; }
|
||||
.step-card p { font-size: .85rem; color: var(--text-muted); line-height: 1.6; }
|
||||
.step-arrow {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 800px) {
|
||||
.step-card::after {
|
||||
content: '→';
|
||||
position: absolute; right: -22px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.4rem; color: var(--border);
|
||||
}
|
||||
.steps > .step-card:last-child::after { display: none; }
|
||||
}
|
||||
|
||||
/* ── DOMAIN EXAMPLE ───────────────────────────────────────── */
|
||||
.example-section {
|
||||
background: var(--blue-dark);
|
||||
padding: 72px 24px;
|
||||
}
|
||||
.example-section .section-title { color: #fff; }
|
||||
.example-section .section-label { color: #60a5fa; }
|
||||
|
||||
.example-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.example-card {
|
||||
background: rgba(255,255,255,.07);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: var(--radius); padding: 22px 20px;
|
||||
}
|
||||
.example-card__label { font-size: .72rem; color: rgba(255,255,255,.5); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.example-card__cdn { font-size: .85rem; color: #93c5fd; font-family: monospace; margin-bottom: 6px; }
|
||||
.example-card__domain { font-size: 1.2rem; font-weight: 800; color: #fff; }
|
||||
.example-card__domain .kt-part { color: #60a5fa; }
|
||||
.example-card__note { font-size: .76rem; color: rgba(255,255,255,.5); margin-top: 8px; }
|
||||
|
||||
/* ── FEATURES ─────────────────────────────────────────────── */
|
||||
.features-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
padding: 24px 20px; box-shadow: var(--shadow);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.feature-icon {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
background: var(--blue-light); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 1.3rem;
|
||||
}
|
||||
.feature-card h3 { font-size: .92rem; font-weight: 700; }
|
||||
.feature-card p { font-size: .8rem; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
/* ── CTA BANNER ───────────────────────────────────────────── */
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, var(--blue) 0%, #0077cc 100%);
|
||||
padding: 72px 24px; text-align: center;
|
||||
}
|
||||
.cta-section h2 { font-size: 2rem; font-weight: 800; color: #fff; margin-bottom: 12px; }
|
||||
.cta-section p { color: rgba(255,255,255,.75); margin-bottom: 32px; }
|
||||
.btn-cta {
|
||||
display: inline-block; padding: 14px 36px; border-radius: 10px;
|
||||
background: #fff; color: var(--blue-dark); font-weight: 800;
|
||||
font-size: 1rem; text-decoration: none; transition: transform .15s, box-shadow .15s;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.2);
|
||||
}
|
||||
.btn-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(0,0,0,.25); }
|
||||
|
||||
/* ── FOOTER ───────────────────────────────────────────────── */
|
||||
footer {
|
||||
background: var(--blue-dark); color: rgba(255,255,255,.6);
|
||||
padding: 32px 40px; display: flex; justify-content: space-between;
|
||||
align-items: center; flex-wrap: wrap; gap: 12px; font-size: .82rem;
|
||||
}
|
||||
footer .footer-logo { color: #fff; font-weight: 700; }
|
||||
footer a { color: rgba(255,255,255,.6); text-decoration: none; }
|
||||
footer a:hover { color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── NAV ─────────────────────────────────────────────────────── -->
|
||||
<nav>
|
||||
<a href="index.html" class="nav-logo">
|
||||
<span class="dot-kt">.kt</span>
|
||||
domain.kt
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#how">Как это работает</a>
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#examples">Примеры</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a href="login.html" class="btn-outline">Войти</a>
|
||||
<a href="login.html#register" class="btn-primary-sm">Зарегистрироваться</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── HERO ────────────────────────────────────────────────────── -->
|
||||
<section class="hero">
|
||||
<div class="hero-badge">
|
||||
<span class="pulse"></span>
|
||||
Внутренняя сеть Казахтелеком · Только для сотрудников
|
||||
</div>
|
||||
|
||||
<h1>Ваш корпоративный домен<br><span class="accent">yourname.kt</span></h1>
|
||||
<p>Зарегистрируйте персональный или проектный домен второго уровня в зоне <strong style="color:#fff;">.kt</strong>. Автоматически на основе вашего CDN или любое свободное имя.</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<div class="search-row">
|
||||
<input class="search-input" id="searchInput" type="text"
|
||||
placeholder="введите имя домена..." autocomplete="off"
|
||||
oninput="clearResult()" />
|
||||
<span class="search-suffix">.kt</span>
|
||||
<button class="search-btn" onclick="checkDomain()">Проверить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-hint">Например: <em>tuleev</em>, <em>hr-portal</em>, <em>devops-team</em> — домен второго уровня в .kt</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div class="search-result" id="searchResult">
|
||||
<div class="result-card" id="resultCard"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat-item"><div class="stat-val">1 240</div><div class="stat-label">доменов зарегистрировано</div></div>
|
||||
<div class="stat-item"><div class="stat-val">3 800+</div><div class="stat-label">сотрудников в системе</div></div>
|
||||
<div class="stat-item"><div class="stat-val">< 5 мин</div><div class="stat-label">среднее время регистрации</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── HOW IT WORKS ─────────────────────────────────────────────── -->
|
||||
<section class="section" id="how">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Процесс</div>
|
||||
<div class="section-title">Как зарегистрировать домен</div>
|
||||
<div class="steps">
|
||||
<div class="step-card">
|
||||
<div class="step-num">1</div>
|
||||
<h3>Войдите в систему</h3>
|
||||
<p>Используйте ваш CDN, корпоративный email и пароль. Первый вход — регистрация аккаунта за 2 минуты.</p>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-num">2</div>
|
||||
<h3>Получите домен по CDN</h3>
|
||||
<p>Система автоматически предложит домен на основе вашего CDN. Например, CDN <code>tuleev.a</code> → домен <code>tuleev.kt</code>.</p>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-num">3</div>
|
||||
<h3>Добавьте проектные домены</h3>
|
||||
<p>Зарегистрируйте дополнительные домены для ваших проектов или команды. Проверьте доступность прямо здесь.</p>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-num">4</div>
|
||||
<h3>Управляйте из кабинета</h3>
|
||||
<p>Просматривайте все домены, меняйте настройки, переименовывайте — всё в одном месте.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── DOMAIN EXAMPLES ──────────────────────────────────────────── -->
|
||||
<section class="example-section" id="examples">
|
||||
<div style="max-width:960px; margin:0 auto;">
|
||||
<div class="section-label">Примеры</div>
|
||||
<div class="section-title">Как формируется ваш домен</div>
|
||||
<div class="example-grid">
|
||||
<div class="example-card">
|
||||
<div class="example-card__label">CDN сотрудника</div>
|
||||
<div class="example-card__cdn">tuleev.a</div>
|
||||
<div class="example-card__domain">tuleev<span class="kt-part">.kt</span></div>
|
||||
<div class="example-card__note">Личный домен второго уровня — автоматически</div>
|
||||
</div>
|
||||
<div class="example-card">
|
||||
<div class="example-card__label">CDN сотрудника</div>
|
||||
<div class="example-card__cdn">abenova.g</div>
|
||||
<div class="example-card__domain">abenova<span class="kt-part">.kt</span></div>
|
||||
<div class="example-card__note">Личный домен второго уровня — автоматически</div>
|
||||
</div>
|
||||
<div class="example-card">
|
||||
<div class="example-card__label">Проектный домен</div>
|
||||
<div class="example-card__cdn">регистрирует: tuleev.a</div>
|
||||
<div class="example-card__domain">devops-team<span class="kt-part">.kt</span></div>
|
||||
<div class="example-card__note">Произвольный — на проверке у IT</div>
|
||||
</div>
|
||||
<div class="example-card">
|
||||
<div class="example-card__label">Проектный домен</div>
|
||||
<div class="example-card__cdn">регистрирует: bekova.m</div>
|
||||
<div class="example-card__domain">hr-portal<span class="kt-part">.kt</span></div>
|
||||
<div class="example-card__note">Произвольный — подтверждён</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── FEATURES ─────────────────────────────────────────────────── -->
|
||||
<section class="section" id="features">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Возможности</div>
|
||||
<div class="section-title">Всё что нужно для корпоративных доменов</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<h3>Поиск доступности</h3>
|
||||
<p>Мгновенная проверка — свободен ли нужный домен в зоне .kt.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Авто-регистрация по CDN</h3>
|
||||
<p>При первом входе система сразу предлагает ваш персональный домен.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📦</div>
|
||||
<h3>Несколько доменов</h3>
|
||||
<p>Личный домен + любое количество проектных. Всё в одном кабинете.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">✏️</div>
|
||||
<h3>Смена домена</h3>
|
||||
<p>Имя домена можно изменить, если новое имя свободно. Без потери данных.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>IT-контроль</h3>
|
||||
<p>Администратор видит все домены, решает конфликты однофамильцев и нестандартные запросы.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔔</div>
|
||||
<h3>Уведомления</h3>
|
||||
<p>Email-оповещения о статусе заявки: одобрено, на проверке, конфликт.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CTA ──────────────────────────────────────────────────────── -->
|
||||
<section class="cta-section">
|
||||
<h2>Зарегистрируйте ваш домен прямо сейчас</h2>
|
||||
<p>Войдите с корпоративным CDN — домен будет готов меньше чем за 5 минут.</p>
|
||||
<a href="login.html" class="btn-cta">Войти и получить домен</a>
|
||||
</section>
|
||||
|
||||
<!-- ── FOOTER ───────────────────────────────────────────────────── -->
|
||||
<footer>
|
||||
<span class="footer-logo">.kt · Казахтелеком</span>
|
||||
<span>Внутренняя корпоративная сеть — только для сотрудников</span>
|
||||
<span><a href="admin.html">Панель администратора</a></span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Mock taken domains
|
||||
const takenDomains = ['it','support','admin','helpdesk','mail','vpn','corp','intranet','hr','finance'];
|
||||
|
||||
function checkDomain() {
|
||||
const raw = document.getElementById('searchInput').value.trim().toLowerCase().replace(/[^a-z0-9-]/g,'');
|
||||
const input = document.getElementById('searchInput');
|
||||
if (!raw) { input.focus(); return; }
|
||||
|
||||
const isTaken = takenDomains.includes(raw);
|
||||
const variations = [raw + '-team', raw + '-dev', raw + '2'];
|
||||
|
||||
let html = `
|
||||
<div class="result-row">
|
||||
<div class="result-domain">${raw}<span>.kt</span></div>
|
||||
${isTaken
|
||||
? `<span class="tag-taken">Занят</span>`
|
||||
: `<span class="tag-free">Свободен</span>
|
||||
<a href="login.html" class="btn-claim">Зарегистрировать</a>`
|
||||
}
|
||||
</div>`;
|
||||
|
||||
if (isTaken) {
|
||||
variations.forEach(v => {
|
||||
html += `
|
||||
<div class="result-row">
|
||||
<div class="result-domain">${v}<span>.kt</span></div>
|
||||
<span class="tag-free">Свободен</span>
|
||||
<a href="login.html" class="btn-claim">Зарегистрировать</a>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('resultCard').innerHTML = html;
|
||||
document.getElementById('searchResult').classList.add('show');
|
||||
}
|
||||
|
||||
function clearResult() {
|
||||
document.getElementById('searchResult').classList.remove('show');
|
||||
}
|
||||
|
||||
document.getElementById('searchInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') checkDomain();
|
||||
});
|
||||
|
||||
// smooth scroll
|
||||
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.querySelector(a.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
397
login.html
Normal file
397
login.html
Normal file
@ -0,0 +1,397 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Вход — domain.kt</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--blue: #005baa;
|
||||
--blue-dark: #003f7a;
|
||||
--blue-light: #e8f0fb;
|
||||
--green: #1ab36e;
|
||||
--red: #d93636;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--bg: #f3f6fb;
|
||||
--card: #ffffff;
|
||||
--radius: 14px;
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
background: linear-gradient(145deg, #002a5c 0%, #005baa 100%);
|
||||
min-height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
position: fixed; top: 20px; left: 28px;
|
||||
color: rgba(255,255,255,.7); text-decoration: none; font-size: .85rem;
|
||||
display: flex; align-items: center; gap: 6px; transition: color .15s;
|
||||
}
|
||||
.back-link:hover { color: #fff; }
|
||||
|
||||
.auth-logo {
|
||||
text-align: center; margin-bottom: 28px;
|
||||
}
|
||||
.auth-logo .dot-kt {
|
||||
display: inline-block; background: #60a5fa; color: var(--blue-dark);
|
||||
font-size: .8rem; font-weight: 800; padding: 2px 8px;
|
||||
border-radius: 6px; margin-right: 6px;
|
||||
}
|
||||
.auth-logo span { color: #fff; font-size: 1.15rem; font-weight: 700; }
|
||||
.auth-logo p { color: rgba(255,255,255,.55); font-size: .8rem; margin-top: 4px; }
|
||||
|
||||
/* ── TABS ─────────────────────────────────────────────────── */
|
||||
.auth-card {
|
||||
background: var(--card); border-radius: 18px;
|
||||
width: 100%; max-width: 440px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabs {
|
||||
display: flex; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tab {
|
||||
flex: 1; padding: 16px; text-align: center;
|
||||
font-size: .88rem; font-weight: 600; cursor: pointer;
|
||||
color: var(--text-muted); border: none; background: none;
|
||||
transition: color .15s, border-color .15s;
|
||||
border-bottom: 3px solid transparent; margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
|
||||
.tab-pane { display: none; padding: 32px; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
/* ── FORM ─────────────────────────────────────────────────── */
|
||||
.form-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.form-subtitle { font-size: .82rem; color: var(--text-muted); margin-bottom: 24px; }
|
||||
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label {
|
||||
display: block; font-size: .78rem; font-weight: 600;
|
||||
color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.input-wrap { position: relative; }
|
||||
.input-wrap .icon {
|
||||
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||
color: #aaa; font-size: .95rem; pointer-events: none;
|
||||
}
|
||||
.input-wrap input {
|
||||
width: 100%; padding: 11px 12px 11px 38px;
|
||||
border: 1.5px solid var(--border); border-radius: 9px;
|
||||
font-size: .9rem; color: var(--text); background: var(--bg);
|
||||
transition: border-color .15s, box-shadow .15s; outline: none;
|
||||
}
|
||||
.input-wrap input:focus {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 3px rgba(0,91,170,.12);
|
||||
background: #fff;
|
||||
}
|
||||
.input-wrap input.error { border-color: var(--red); }
|
||||
|
||||
.cdn-hint {
|
||||
font-size: .75rem; color: var(--text-muted); margin-top: 5px;
|
||||
}
|
||||
.cdn-hint code {
|
||||
background: var(--bg); padding: 1px 5px; border-radius: 4px;
|
||||
font-size: .78rem; color: var(--blue);
|
||||
}
|
||||
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
.forgot { text-align: right; margin-top: -8px; margin-bottom: 20px; }
|
||||
.forgot a { font-size: .78rem; color: var(--blue); text-decoration: none; }
|
||||
.forgot a:hover { text-decoration: underline; }
|
||||
|
||||
.btn-submit {
|
||||
width: 100%; padding: 13px; border-radius: 9px;
|
||||
background: var(--blue); color: #fff; font-size: .95rem;
|
||||
font-weight: 700; border: none; cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
}
|
||||
.btn-submit:hover { background: var(--blue-dark); }
|
||||
.btn-submit:active { transform: scale(.98); }
|
||||
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 20px 0; color: var(--text-muted); font-size: .78rem;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: ''; flex: 1; height: 1px; background: var(--border);
|
||||
}
|
||||
|
||||
/* inline error */
|
||||
.err-msg {
|
||||
display: none; font-size: .78rem; color: var(--red);
|
||||
margin-top: 5px;
|
||||
}
|
||||
.err-msg.show { display: block; }
|
||||
|
||||
/* success state */
|
||||
.success-block {
|
||||
display: none; text-align: center; padding: 16px 0;
|
||||
}
|
||||
.success-block .check {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: #e6f9f0; display: flex; align-items: center;
|
||||
justify-content: center; margin: 0 auto 14px; font-size: 1.6rem;
|
||||
}
|
||||
.success-block h3 { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.success-block p { font-size: .83rem; color: var(--text-muted); }
|
||||
.success-block a { color: var(--blue); text-decoration: none; font-weight: 600; }
|
||||
|
||||
/* info box */
|
||||
.info-box {
|
||||
background: var(--blue-light); border-radius: 9px;
|
||||
padding: 12px 14px; font-size: .8rem; color: var(--blue);
|
||||
margin-bottom: 20px; line-height: 1.5;
|
||||
}
|
||||
.info-box strong { display: block; margin-bottom: 2px; }
|
||||
|
||||
/* password toggle */
|
||||
.toggle-pw {
|
||||
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||
cursor: pointer; color: #aaa; font-size: .85rem; user-select: none;
|
||||
}
|
||||
.toggle-pw:hover { color: var(--text-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="index.html" class="back-link">← Вернуться на главную</a>
|
||||
|
||||
<div class="auth-logo">
|
||||
<div><span class="dot-kt">.kt</span><span>domain.kt</span></div>
|
||||
<p>Корпоративный регистратор доменов Казахтелеком</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('login')">Войти</button>
|
||||
<button class="tab" onclick="switchTab('register')" id="tab-register">Регистрация</button>
|
||||
</div>
|
||||
|
||||
<!-- ── LOGIN PANE ──────────────────────────────────────────── -->
|
||||
<div class="tab-pane active" id="pane-login">
|
||||
<div class="form-title">Добро пожаловать</div>
|
||||
<div class="form-subtitle">Войдите с корпоративным CDN и паролем</div>
|
||||
|
||||
<div class="field">
|
||||
<label>CDN (корпоративный идентификатор)</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">👤</span>
|
||||
<input type="text" id="login-cdn" placeholder="например: tuleev.a" autocomplete="username" />
|
||||
</div>
|
||||
<div class="cdn-hint">Формат: <code>фамилия.инициал</code> — например <code>tuleev.a</code></div>
|
||||
<div class="err-msg" id="err-login-cdn">Введите ваш CDN</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Пароль</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">🔒</span>
|
||||
<input type="password" id="login-pwd" placeholder="••••••••" autocomplete="current-password" />
|
||||
<span class="toggle-pw" onclick="togglePw('login-pwd', this)">показать</span>
|
||||
</div>
|
||||
<div class="err-msg" id="err-login-pwd">Введите пароль</div>
|
||||
</div>
|
||||
|
||||
<div class="forgot"><a href="#">Забыли пароль?</a></div>
|
||||
|
||||
<button class="btn-submit" onclick="doLogin()">Войти в кабинет</button>
|
||||
</div>
|
||||
|
||||
<!-- ── REGISTER PANE ───────────────────────────────────────── -->
|
||||
<div class="tab-pane" id="pane-register">
|
||||
<div class="form-title">Создать аккаунт</div>
|
||||
<div class="form-subtitle">Заполните данные для первичной регистрации</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Что такое CDN?</strong>
|
||||
Корпоративный идентификатор сотрудника — выдаётся отделом кадров.
|
||||
Формат: <strong>фамилия.инициал</strong>, например <code>tuleev.a</code>.
|
||||
Ваш будущий домен: <strong>tuleev.kt</strong>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>CDN</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">🪪</span>
|
||||
<input type="text" id="reg-cdn" placeholder="tuleev.a"
|
||||
oninput="previewDomain()" autocomplete="off" />
|
||||
</div>
|
||||
<div class="cdn-hint" id="domain-preview" style="color:var(--blue);font-weight:600;"></div>
|
||||
<div class="err-msg" id="err-reg-cdn">Введите ваш CDN в формате фамилия.инициал</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Имя</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">✏️</span>
|
||||
<input type="text" id="reg-name" placeholder="Аскар" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Фамилия</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">✏️</span>
|
||||
<input type="text" id="reg-surname" placeholder="Тулеев" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Корпоративный email</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">📧</span>
|
||||
<input type="email" id="reg-email" placeholder="tuleev.a@telecom.kz" />
|
||||
</div>
|
||||
<div class="err-msg" id="err-reg-email">Введите корпоративный email (@telecom.kz)</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Отдел / Подразделение</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">🏢</span>
|
||||
<input type="text" id="reg-dept" placeholder="Департамент цифровых продуктов" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Пароль</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">🔒</span>
|
||||
<input type="password" id="reg-pwd" placeholder="Минимум 8 символов" />
|
||||
<span class="toggle-pw" onclick="togglePw('reg-pwd', this)">показать</span>
|
||||
</div>
|
||||
<div class="err-msg" id="err-reg-pwd">Пароль должен быть не менее 8 символов</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Повторите пароль</label>
|
||||
<div class="input-wrap">
|
||||
<span class="icon">🔒</span>
|
||||
<input type="password" id="reg-pwd2" placeholder="Повторите пароль" />
|
||||
</div>
|
||||
<div class="err-msg" id="err-reg-pwd2">Пароли не совпадают</div>
|
||||
</div>
|
||||
|
||||
<!-- Success state (hidden initially) -->
|
||||
<div class="success-block" id="reg-success">
|
||||
<div class="check">✅</div>
|
||||
<h3>Аккаунт создан!</h3>
|
||||
<p>Письмо с подтверждением отправлено на ваш корпоративный email.<br>
|
||||
<a href="dashboard.html">Перейти в личный кабинет →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn-submit" id="reg-btn" onclick="doRegister()">Создать аккаунт</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { api, saveSession, getToken } from './app.js';
|
||||
|
||||
// Already logged in? bounce to dashboard.
|
||||
if (getToken()) window.location.href = 'dashboard.html';
|
||||
|
||||
// ── Tab switching ──────────────────────────────────────────────
|
||||
window.switchTab = function(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.tab')[name === 'login' ? 0 : 1].classList.add('active');
|
||||
document.getElementById('pane-' + name).classList.add('active');
|
||||
};
|
||||
|
||||
if (location.hash === '#register') window.switchTab('register');
|
||||
|
||||
window.previewDomain = function() {
|
||||
const cdn = document.getElementById('reg-cdn').value.trim().toLowerCase();
|
||||
const preview = document.getElementById('domain-preview');
|
||||
if (!cdn) { preview.textContent = ''; return; }
|
||||
const surname = cdn.split('.')[0];
|
||||
if (surname) preview.textContent = `Ваш домен будет: ${surname}.kt`;
|
||||
};
|
||||
|
||||
window.togglePw = function(fieldId, btn) {
|
||||
const f = document.getElementById(fieldId);
|
||||
if (f.type === 'password') { f.type = 'text'; btn.textContent = 'скрыть'; }
|
||||
else { f.type = 'password'; btn.textContent = 'показать'; }
|
||||
};
|
||||
|
||||
function setErr(id, show, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (text) el.textContent = text;
|
||||
if (show) el.classList.add('show'); else el.classList.remove('show');
|
||||
}
|
||||
|
||||
window.doLogin = async function() {
|
||||
const cdn = document.getElementById('login-cdn').value.trim();
|
||||
const pwd = document.getElementById('login-pwd').value;
|
||||
setErr('err-login-cdn', !cdn);
|
||||
setErr('err-login-pwd', !pwd);
|
||||
if (!cdn || !pwd) return;
|
||||
try {
|
||||
const data = await api('/auth/login', { method: 'POST', body: { cdn, password: pwd } });
|
||||
saveSession(data.token, { cdn: data.cdn, name: data.name, role: data.role });
|
||||
window.location.href = data.role === 'admin' ? 'admin.html' : 'dashboard.html';
|
||||
} catch (e) {
|
||||
setErr('err-login-pwd', true, e.message || 'Неверный CDN или пароль');
|
||||
}
|
||||
};
|
||||
|
||||
window.doRegister = async function() {
|
||||
const cdn = document.getElementById('reg-cdn').value.trim();
|
||||
const name = document.getElementById('reg-name').value.trim();
|
||||
const surname = document.getElementById('reg-surname').value.trim();
|
||||
const email = document.getElementById('reg-email').value.trim();
|
||||
const dept = document.getElementById('reg-dept').value.trim();
|
||||
const pwd = document.getElementById('reg-pwd').value;
|
||||
const pwd2 = document.getElementById('reg-pwd2').value;
|
||||
|
||||
const cdnOk = /^[a-z0-9-]+\.[a-z]$/i.test(cdn);
|
||||
const emailOk = /\S+@\S+\.\S+/.test(email);
|
||||
const pwdOk = pwd.length >= 1;
|
||||
const pwd2Ok = pwd === pwd2;
|
||||
|
||||
setErr('err-reg-cdn', !cdnOk);
|
||||
setErr('err-reg-email', !emailOk);
|
||||
setErr('err-reg-pwd', !pwdOk);
|
||||
setErr('err-reg-pwd2', !pwd2Ok);
|
||||
if (!cdnOk || !emailOk || !pwdOk || !pwd2Ok) return;
|
||||
|
||||
const fullName = (name + ' ' + surname).trim() || cdn;
|
||||
try {
|
||||
const data = await api('/auth/register', {
|
||||
method: 'POST',
|
||||
body: { cdn, name: fullName, email, department: dept, password: pwd },
|
||||
});
|
||||
saveSession(data.token, { cdn: data.cdn, name: data.name, role: data.role });
|
||||
document.getElementById('reg-btn').style.display = 'none';
|
||||
const ok = document.getElementById('reg-success');
|
||||
ok.style.display = 'block';
|
||||
ok.querySelector('h3').textContent = 'Аккаунт создан!';
|
||||
ok.querySelector('p').innerHTML =
|
||||
`Ваш персональный домен <strong>${data.previewDomain}</strong> регистрируется в Zorro (2–10 секунд).<br>` +
|
||||
`<a href="dashboard.html">Перейти в личный кабинет →</a>`;
|
||||
} catch (e) {
|
||||
setErr('err-reg-cdn', true, e.message || 'Не удалось зарегистрироваться');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (document.getElementById('pane-login').classList.contains('active')) window.doLogin();
|
||||
else window.doRegister();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
136
prd.md
Normal file
136
prd.md
Normal file
@ -0,0 +1,136 @@
|
||||
# PRD — domain.kt
|
||||
|
||||
**Тип:** MVP
|
||||
**Владелец:** Аскар Тулеев
|
||||
**Аудитория документа:** разработчики, системные аналитики
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
Внутри Казахтелеком нет централизованного инструмента для управления корпоративными доменными именами сотрудников. Каждый проект придумывает адресацию по-своему, нет единого реестра, нет процесса согласования. Хочется простой, управляемый процесс с прозрачным статусом на каждом шаге.
|
||||
|
||||
Параллельная задача: обкатать Zorro как внутренний BPMN-движок на реальном, но несложном процессе.
|
||||
|
||||
---
|
||||
|
||||
## Что делает продукт
|
||||
|
||||
Позволяет сотруднику Казахтелеком:
|
||||
- получить персональный домен второго уровня в зоне `.kt` автоматически, на основе CDN
|
||||
- зарегистрировать дополнительные проектные домены
|
||||
- сменить имя домена, если оно свободно
|
||||
- видеть статус всех своих доменов в одном месте
|
||||
|
||||
Позволяет IT-администратору:
|
||||
- видеть полный реестр доменов `.kt`
|
||||
- одобрять или отклонять проектные заявки
|
||||
- разрешать конфликты (например, два сотрудника с одинаковой фамилией)
|
||||
- деактивировать домен при необходимости
|
||||
|
||||
---
|
||||
|
||||
## Пользователи
|
||||
|
||||
### Сотрудник
|
||||
Рядовой сотрудник Казахтелеком. Работает через `index.html` → `login.html` → `dashboard.html`. Знает свой CDN (выдаётся HR). Хочет быстро получить домен и не думать о деталях.
|
||||
|
||||
### IT-администратор
|
||||
Сотрудник IT-отдела. Работает через `admin.html`. Рассматривает нестандартные заявки, разрешает конфликты, управляет реестром.
|
||||
|
||||
---
|
||||
|
||||
## Функциональные требования
|
||||
|
||||
### Поиск домена (публичная страница)
|
||||
- Поле `[ввод].kt` — пользователь видит суффикс справа, вводит только имя
|
||||
- Мгновенная проверка: занят / свободен / зарезервирован
|
||||
- Если занят — показать похожие свободные варианты
|
||||
- Кнопка "Зарегистрировать" ведёт на логин
|
||||
|
||||
### Аутентификация
|
||||
- Вход: CDN + пароль + корпоративный email
|
||||
- При первом входе — регистрация аккаунта
|
||||
- Превью домена в реальном времени при вводе CDN: `tuleev.a` → `Ваш домен: tuleev.kt`
|
||||
|
||||
### Кабинет сотрудника
|
||||
- Список всех доменов с карточками (статус, DNS-запись, дата)
|
||||
- Регистрация нового домена: поле `[ввод].kt` + тип (персональный / проектный) + назначение + DNS
|
||||
- Смена имени: ввод нового имени, проверка доступности, отправка на одобрение
|
||||
- История заявок
|
||||
|
||||
### Панель администратора
|
||||
- Реестр всех доменов с фильтрами (статус, тип, поиск по имени/CDN)
|
||||
- Очередь на проверке: одобрить / отклонить с причиной / предложить альтернативу
|
||||
- Раздел конфликтов: видно кто с кем конфликтует, кнопка быстрого разрешения
|
||||
- Список сотрудников и их доменов
|
||||
- Журнал действий администратора
|
||||
- Настройки: авто-одобрение, лимит доменов, зарезервированные имена
|
||||
|
||||
---
|
||||
|
||||
## Нефункциональные требования
|
||||
|
||||
| Параметр | Ожидание |
|
||||
|---|---|
|
||||
| Время активации персонального домена | Секунды (авто, без участия человека) |
|
||||
| Время рассмотрения проектного домена | 1–2 рабочих дня (IT-администратор) |
|
||||
| Эскалация при бездействии IT | 48 часов → напоминание старшему |
|
||||
| Количество доменов на сотрудника | Лимит настраивается, дефолт: без ограничений |
|
||||
| Аутентификация | CDN + пароль (MVP); SSO/LDAP — следующий этап |
|
||||
|
||||
---
|
||||
|
||||
## Логика формирования домена
|
||||
|
||||
```
|
||||
CDN: tuleev.a
|
||||
└─ берём часть до точки: "tuleev"
|
||||
└─ добавляем зону: "tuleev.kt"
|
||||
|
||||
Если "tuleev.kt" уже занят:
|
||||
└─ флаг конфликта → IT-администратор
|
||||
└─ может предложить: tuleev-a.kt, tuleev2.kt
|
||||
```
|
||||
|
||||
Проектный домен — произвольное имя, которое сотрудник вводит сам. Требует одобрения IT.
|
||||
|
||||
---
|
||||
|
||||
## Ограничения MVP
|
||||
|
||||
- Без реального DNS: провизия имитируется (поле DNS-записи заполняется вручную)
|
||||
- Без реального LDAP/AD: валидация CDN — по базе данных приложения
|
||||
- Без SSO: простая форма логин/пароль
|
||||
- Без автоматического продления доменов
|
||||
- Один регион, одна корпоративная зона `.kt`
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
```
|
||||
Как сотрудник, я хочу:
|
||||
- ввести свой CDN и сразу увидеть, какой домен мне достанется
|
||||
- зарегистрировать домен в один клик, без звонков в IT
|
||||
- видеть статус своей заявки в реальном времени
|
||||
- зарегистрировать отдельный домен для своего проекта
|
||||
- сменить имя домена, если найду лучше
|
||||
|
||||
Как IT-администратор, я хочу:
|
||||
- видеть все заявки в одном месте, а не в почте
|
||||
- одобрить заявку одной кнопкой
|
||||
- при конфликте фамилий — быстро назначить альтернативу обоим
|
||||
- заблокировать системные имена (admin, mail, vpn...)
|
||||
- посмотреть журнал: кто что делал
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что не входит в MVP
|
||||
|
||||
- Управление DNS-записями внутри домена (субдомены, MX, TXT)
|
||||
- Публичный WHOIS-сервис
|
||||
- Делегирование домена другому сотруднику
|
||||
- Оплата / SLA-контракты
|
||||
- Интеграция с Active Directory
|
||||
39
server/auth.js
Normal file
39
server/auth.js
Normal file
@ -0,0 +1,39 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SECRET_FILE = path.join(__dirname, '.jwt-secret');
|
||||
|
||||
function loadSecret() {
|
||||
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
}
|
||||
|
||||
const SECRET = loadSecret();
|
||||
|
||||
export function sign(payload) {
|
||||
return jwt.sign(payload, SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'No token' });
|
||||
try {
|
||||
req.user = jwt.verify(token, SECRET);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
next();
|
||||
}
|
||||
111
server/db.js
Normal file
111
server/db.js
Normal file
@ -0,0 +1,111 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.sqlite');
|
||||
|
||||
export const db = new DatabaseSync(dbPath);
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cdn TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
department TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'employee',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
owner_cdn TEXT NOT NULL REFERENCES employees(cdn),
|
||||
type TEXT NOT NULL CHECK (type IN ('personal','project')),
|
||||
status TEXT NOT NULL CHECK (status IN ('active','pending','conflict','inactive')),
|
||||
dns_record TEXT,
|
||||
purpose TEXT,
|
||||
zorro_instance_key TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER REFERENCES domains(id),
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
initiated_by TEXT REFERENCES employees(cdn),
|
||||
resolved_by TEXT REFERENCES employees(cdn),
|
||||
admin_comment TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_cdn TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
details TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS process_variables (
|
||||
process_instance_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'STRING',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (process_instance_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS process_claims (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
claimed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
export function setVar(pid, name, value, type = 'STRING') {
|
||||
db.prepare(
|
||||
`INSERT INTO process_variables (process_instance_id, name, value, type, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(process_instance_id, name) DO UPDATE SET value=excluded.value, type=excluded.type, updated_at=datetime('now')`
|
||||
).run(pid, name, value == null ? null : String(value), type);
|
||||
}
|
||||
|
||||
export function setVars(pid, obj) {
|
||||
for (const [name, v] of Object.entries(obj)) {
|
||||
const type = typeof v === 'boolean' ? 'BOOLEAN' : 'STRING';
|
||||
setVar(pid, name, v, type);
|
||||
}
|
||||
}
|
||||
|
||||
export function getVars(pid) {
|
||||
const rows = db.prepare('SELECT name, value, type FROM process_variables WHERE process_instance_id = ?').all(pid);
|
||||
const out = {};
|
||||
for (const r of rows) {
|
||||
if (r.type === 'BOOLEAN') out[r.name] = r.value === 'true';
|
||||
else out[r.name] = r.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tryClaim(taskId) {
|
||||
try {
|
||||
db.prepare('INSERT INTO process_claims (task_id) VALUES (?)').run(taskId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function audit(actorCdn, action, target, details) {
|
||||
db.prepare(
|
||||
'INSERT INTO audit_log (actor_cdn, action, target, details) VALUES (?,?,?,?)'
|
||||
).run(actorCdn, action, target, details ? JSON.stringify(details) : null);
|
||||
}
|
||||
134
server/domains.js
Normal file
134
server/domains.js
Normal file
@ -0,0 +1,134 @@
|
||||
import { db, audit } from './db.js';
|
||||
import * as pdns from './pdns.js';
|
||||
|
||||
const RESERVED = new Set([
|
||||
'admin', 'www', 'mail', 'vpn', 'ns', 'ns1', 'ns2', 'api', 'app', 'dev',
|
||||
'test', 'staging', 'prod', 'root', 'kt',
|
||||
]);
|
||||
|
||||
const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
export function normalize(name) {
|
||||
return (name || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
export function validateName(name) {
|
||||
if (!name) return 'Name required';
|
||||
if (name.length < 2 || name.length > 63) return 'Length 2..63';
|
||||
if (!NAME_RE.test(name)) return 'Only a-z, 0-9, hyphen; no leading/trailing hyphen';
|
||||
if (RESERVED.has(name)) return 'Reserved name';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function personalNameFromCdn(cdn) {
|
||||
return normalize(cdn.split('.')[0]);
|
||||
}
|
||||
|
||||
export function isTaken(name) {
|
||||
return !!db.prepare('SELECT 1 FROM domains WHERE name = ?').get(name);
|
||||
}
|
||||
|
||||
export function suggest(name) {
|
||||
const out = [];
|
||||
for (let i = 2; i <= 5 && out.length < 3; i++) {
|
||||
const cand = `${name}${i}`;
|
||||
if (!isTaken(cand) && !validateName(cand)) out.push(`${cand}.kt`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function provisionDns(name, dnsRecord) {
|
||||
const defaultIp = process.env.DEFAULT_DNS_IP || '192.168.68.59';
|
||||
const ip = dnsRecord && /^\d+\.\d+\.\d+\.\d+$/.test(dnsRecord) ? dnsRecord : defaultIp;
|
||||
await pdns.createA(name, ip);
|
||||
return ip;
|
||||
}
|
||||
|
||||
export async function registerPersonal(employee) {
|
||||
const name = personalNameFromCdn(employee.cdn);
|
||||
const err = validateName(name);
|
||||
if (err) throw new Error(`Cannot derive personal domain from CDN: ${err}`);
|
||||
if (isTaken(name)) {
|
||||
const info = db
|
||||
.prepare('INSERT INTO domains (name, owner_cdn, type, status) VALUES (?,?,?,?)')
|
||||
.run(`${name}-${Date.now()}`, employee.cdn, 'personal', 'conflict');
|
||||
audit(employee.cdn, 'domain.conflict', name, { employee: employee.cdn });
|
||||
return { id: info.lastInsertRowid, name, status: 'conflict' };
|
||||
}
|
||||
const ip = await provisionDns(name, null);
|
||||
const info = db
|
||||
.prepare(
|
||||
'INSERT INTO domains (name, owner_cdn, type, status, dns_record) VALUES (?,?,?,?,?)'
|
||||
)
|
||||
.run(name, employee.cdn, 'personal', 'active', ip);
|
||||
audit(employee.cdn, 'domain.registered.personal', name, { ip });
|
||||
return { id: info.lastInsertRowid, name, fqdn: `${name}.kt`, status: 'active', ip };
|
||||
}
|
||||
|
||||
export function registerProject(employee, { name, purpose, dns }) {
|
||||
name = normalize(name);
|
||||
const err = validateName(name);
|
||||
if (err) throw new Error(err);
|
||||
if (isTaken(name)) return { error: 'taken', suggestions: suggest(name) };
|
||||
const info = db
|
||||
.prepare(
|
||||
'INSERT INTO domains (name, owner_cdn, type, status, dns_record, purpose) VALUES (?,?,?,?,?,?)'
|
||||
)
|
||||
.run(name, employee.cdn, 'project', 'pending', dns || null, purpose || null);
|
||||
db.prepare(
|
||||
'INSERT INTO requests (domain_id, type, status, initiated_by) VALUES (?,?,?,?)'
|
||||
).run(info.lastInsertRowid, 'register', 'pending', employee.cdn);
|
||||
audit(employee.cdn, 'domain.requested.project', name, { purpose });
|
||||
return { id: info.lastInsertRowid, name, fqdn: `${name}.kt`, status: 'pending' };
|
||||
}
|
||||
|
||||
export async function approve(domainId, admin, comment) {
|
||||
const d = db.prepare('SELECT * FROM domains WHERE id = ?').get(domainId);
|
||||
if (!d) throw new Error('Not found');
|
||||
if (d.status !== 'pending') throw new Error(`Cannot approve in status ${d.status}`);
|
||||
const ip = await provisionDns(d.name, d.dns_record);
|
||||
db.prepare(
|
||||
"UPDATE domains SET status='active', dns_record=?, updated_at=datetime('now') WHERE id=?"
|
||||
).run(ip, domainId);
|
||||
db.prepare(
|
||||
"UPDATE requests SET status='approved', resolved_by=?, admin_comment=?, resolved_at=datetime('now') WHERE domain_id=? AND status='pending'"
|
||||
).run(admin.cdn, comment || null, domainId);
|
||||
audit(admin.cdn, 'domain.approve', d.name, { comment, ip });
|
||||
return { id: domainId, status: 'active', ip };
|
||||
}
|
||||
|
||||
export function reject(domainId, admin, comment) {
|
||||
const d = db.prepare('SELECT * FROM domains WHERE id = ?').get(domainId);
|
||||
if (!d) throw new Error('Not found');
|
||||
db.prepare(
|
||||
"UPDATE domains SET status='inactive', updated_at=datetime('now') WHERE id=?"
|
||||
).run(domainId);
|
||||
db.prepare(
|
||||
"UPDATE requests SET status='rejected', resolved_by=?, admin_comment=?, resolved_at=datetime('now') WHERE domain_id=? AND status='pending'"
|
||||
).run(admin.cdn, comment || null, domainId);
|
||||
audit(admin.cdn, 'domain.reject', d.name, { comment });
|
||||
return { id: domainId, status: 'inactive' };
|
||||
}
|
||||
|
||||
export function listByOwner(cdn) {
|
||||
return db
|
||||
.prepare("SELECT id, name, (name||'.kt') AS fqdn, type, status, dns_record, created_at FROM domains WHERE owner_cdn = ? ORDER BY created_at DESC")
|
||||
.all(cdn);
|
||||
}
|
||||
|
||||
export function adminList(filter = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.status) { where.push('d.status = ?'); params.push(filter.status); }
|
||||
if (filter.type) { where.push('d.type = ?'); params.push(filter.type); }
|
||||
if (filter.search) { where.push('d.name LIKE ?'); params.push(`%${filter.search}%`); }
|
||||
const sql = `
|
||||
SELECT d.id, d.name, (d.name||'.kt') AS fqdn, d.type, d.status, d.purpose, d.dns_record,
|
||||
d.owner_cdn, e.name AS owner_name, e.email AS owner_email, d.created_at
|
||||
FROM domains d LEFT JOIN employees e ON e.cdn = d.owner_cdn
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 500
|
||||
`;
|
||||
return db.prepare(sql).all(...params);
|
||||
}
|
||||
205
server/index.js
Normal file
205
server/index.js
Normal file
@ -0,0 +1,205 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { db, audit, setVars, getVars } from './db.js';
|
||||
import { sign, requireAuth, requireAdmin } from './auth.js';
|
||||
import * as domains from './domains.js';
|
||||
import * as zorro from './zorro.js';
|
||||
import { startWorkers } from './workers.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STATIC_DIR = path.resolve(__dirname, '..');
|
||||
const PORT = Number(process.env.PORT || 8800);
|
||||
const PROCESS_KEY = process.env.PROCESS_KEY || 'kt_domain_registration';
|
||||
const PROCESS_VERSION = Number(process.env.PROCESS_VERSION || 1);
|
||||
const DEFAULT_DNS_IP = process.env.DEFAULT_DNS_IP || '192.168.68.59';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
async function startRegistration(employee, { domainType, requestedName, purpose, dnsTarget }) {
|
||||
const vars = {
|
||||
employeeCdn: employee.cdn,
|
||||
domainType,
|
||||
requestedName: requestedName || '',
|
||||
employeeEmail: employee.email,
|
||||
purpose: purpose || '',
|
||||
dnsRecordType: 'A',
|
||||
dnsTarget: dnsTarget || DEFAULT_DNS_IP,
|
||||
};
|
||||
const pid = await zorro.startProcess(PROCESS_KEY, PROCESS_VERSION, vars);
|
||||
setVars(pid, vars);
|
||||
audit(employee.cdn, 'process.started', pid, { domainType, requestedName });
|
||||
return pid;
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
app.post('/auth/register', async (req, res) => {
|
||||
try {
|
||||
const { cdn, name, email, department, password } = req.body || {};
|
||||
if (!cdn || !name || !email || !password) return res.status(400).json({ error: 'Missing fields' });
|
||||
const cdnNorm = cdn.toLowerCase().trim();
|
||||
if (!/^[a-z0-9-]+\.[a-z]$/.test(cdnNorm)) return res.status(400).json({ error: 'CDN format: surname.x' });
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
let info;
|
||||
try {
|
||||
info = db
|
||||
.prepare('INSERT INTO employees (cdn, name, email, department, password_hash) VALUES (?,?,?,?,?)')
|
||||
.run(cdnNorm, name, email, department || null, hash);
|
||||
} catch (e) {
|
||||
if (String(e.message).includes('UNIQUE')) return res.status(409).json({ error: 'CDN or email already used' });
|
||||
throw e;
|
||||
}
|
||||
const employee = { id: info.lastInsertRowid, cdn: cdnNorm, name, email, role: 'employee' };
|
||||
const token = sign({ cdn: cdnNorm, role: 'employee', name });
|
||||
let processInstanceId = null;
|
||||
try {
|
||||
processInstanceId = await startRegistration(employee, { domainType: 'personal' });
|
||||
} catch (e) {
|
||||
console.error('[auth.register] Zorro start failed:', e.message);
|
||||
}
|
||||
res.json({
|
||||
token, role: 'employee', name, cdn: cdnNorm,
|
||||
previewDomain: `${domains.personalNameFromCdn(cdnNorm)}.kt`,
|
||||
processInstanceId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (req, res) => {
|
||||
const { cdn, password } = req.body || {};
|
||||
if (!cdn || !password) return res.status(400).json({ error: 'Missing fields' });
|
||||
const row = db.prepare('SELECT * FROM employees WHERE cdn = ?').get(cdn.toLowerCase().trim());
|
||||
if (!row) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const ok = await bcrypt.compare(password, row.password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const token = sign({ cdn: row.cdn, role: row.role, name: row.name });
|
||||
res.json({ token, role: row.role, name: row.name, cdn: row.cdn });
|
||||
});
|
||||
|
||||
app.get('/auth/me', requireAuth, (req, res) => {
|
||||
const row = db.prepare('SELECT id, cdn, name, email, department, role FROM employees WHERE cdn = ?').get(req.user.cdn);
|
||||
if (!row) return res.status(404).json({ error: 'User vanished' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// --- Domains ---
|
||||
app.get('/domains/check', (req, res) => {
|
||||
const name = domains.normalize(req.query.name);
|
||||
const err = domains.validateName(name);
|
||||
if (err) return res.json({ available: false, reason: err });
|
||||
if (domains.isTaken(name)) return res.json({ available: false, reason: 'taken', suggestions: domains.suggest(name) });
|
||||
res.json({ available: true });
|
||||
});
|
||||
|
||||
app.get('/domains', requireAuth, (req, res) => {
|
||||
res.json(domains.listByOwner(req.user.cdn));
|
||||
});
|
||||
|
||||
app.post('/domains/register', requireAuth, async (req, res) => {
|
||||
const employee = db.prepare('SELECT * FROM employees WHERE cdn = ?').get(req.user.cdn);
|
||||
if (!employee) return res.status(401).json({ error: 'Unknown user' });
|
||||
const { type, name, purpose, dns } = req.body || {};
|
||||
if (type !== 'personal' && type !== 'project') {
|
||||
return res.status(400).json({ error: 'type must be personal or project' });
|
||||
}
|
||||
if (type === 'project') {
|
||||
const normalized = domains.normalize(name);
|
||||
const err = domains.validateName(normalized);
|
||||
if (err) return res.status(400).json({ error: err });
|
||||
if (domains.isTaken(normalized)) {
|
||||
return res.status(409).json({ error: 'taken', suggestions: domains.suggest(normalized) });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const pid = await startRegistration(employee, {
|
||||
domainType: type,
|
||||
requestedName: type === 'project' ? domains.normalize(name) : '',
|
||||
purpose,
|
||||
dnsTarget: dns,
|
||||
});
|
||||
res.json({ processInstanceId: pid, status: 'processing' });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(502).json({ error: `Zorro: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Admin ---
|
||||
app.get('/admin/domains', requireAuth, requireAdmin, async (req, res) => {
|
||||
const status = req.query.status;
|
||||
if (status === 'pending') {
|
||||
try {
|
||||
const tasks = await zorro.listUserTasks({
|
||||
candidateGroup: 'kt_domain_admins',
|
||||
assigned: false,
|
||||
completed: false,
|
||||
});
|
||||
const out = [];
|
||||
for (const t of tasks) {
|
||||
const v = getVars(t.processInstanceId);
|
||||
if (!v.employeeCdn) continue; // foreign process instance — not ours
|
||||
out.push({
|
||||
userTaskId: t.id,
|
||||
processInstanceId: t.processInstanceId,
|
||||
name: v.finalDomainName || v.requestedName || '',
|
||||
fqdn: `${v.finalDomainName || v.requestedName || ''}.kt`,
|
||||
type: v.domainType || 'project',
|
||||
owner_cdn: v.employeeCdn,
|
||||
owner_email: v.employeeEmail,
|
||||
purpose: v.purpose || '',
|
||||
dns_record: v.dnsTarget,
|
||||
createdAt: t.createdAt,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
return res.json(out);
|
||||
} catch (e) {
|
||||
console.error('[admin] Zorro:', e.message);
|
||||
return res.status(502).json({ error: `Zorro: ${e.message}` });
|
||||
}
|
||||
}
|
||||
res.json(domains.adminList(req.query));
|
||||
});
|
||||
|
||||
app.post('/admin/user-tasks/:id/approve', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await zorro.completeUserTask(req.params.id, {
|
||||
adminDecision: 'APPROVE',
|
||||
adminComment: req.body?.comment || '',
|
||||
});
|
||||
audit(req.user.cdn, 'userTask.approve', req.params.id, req.body || {});
|
||||
res.json({ status: 'approved' });
|
||||
} catch (e) { res.status(502).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.post('/admin/user-tasks/:id/reject', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await zorro.completeUserTask(req.params.id, {
|
||||
adminDecision: 'REJECT',
|
||||
adminComment: req.body?.comment || '',
|
||||
});
|
||||
audit(req.user.cdn, 'userTask.reject', req.params.id, req.body || {});
|
||||
res.json({ status: 'rejected' });
|
||||
} catch (e) { res.status(502).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// --- Debug ---
|
||||
app.get('/admin/process/:pid', requireAuth, requireAdmin, (req, res) => {
|
||||
res.json({ processInstanceId: req.params.pid, variables: getVars(req.params.pid) });
|
||||
});
|
||||
|
||||
// --- Static frontend (last, so API routes win) ---
|
||||
app.use(express.static(STATIC_DIR, { extensions: ['html'] }));
|
||||
app.get('/', (_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`domain.kt server on :${PORT}`);
|
||||
console.log(`static from ${STATIC_DIR}`);
|
||||
console.log(`Zorro at ${process.env.ZORRO_URL || 'http://10.9.0.12:9092'}`);
|
||||
startWorkers();
|
||||
});
|
||||
953
server/package-lock.json
generated
Normal file
953
server/package-lock.json
generated
Normal file
@ -0,0 +1,953 @@
|
||||
{
|
||||
"name": "domain-kt-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "domain-kt-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
server/package.json
Normal file
15
server/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "domain-kt-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
}
|
||||
42
server/pdns.js
Normal file
42
server/pdns.js
Normal file
@ -0,0 +1,42 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const KEY_FILE = process.env.PDNS_KEY_FILE || path.join(os.homedir(), '.pdns-api-key');
|
||||
const PDNS_API = process.env.PDNS_API || 'http://127.0.0.1:8081/api/v1/servers/localhost';
|
||||
const ZONE = process.env.PDNS_ZONE || 'kt.';
|
||||
|
||||
function key() {
|
||||
return fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
}
|
||||
|
||||
async function patchZone(rrsets) {
|
||||
const res = await fetch(`${PDNS_API}/zones/${ZONE}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-API-Key': key(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rrsets }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`PowerDNS ${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createA(name, ip, ttl = 60) {
|
||||
const fqdn = name.endsWith('.') ? name : `${name}.${ZONE}`;
|
||||
await patchZone([
|
||||
{
|
||||
name: fqdn,
|
||||
type: 'A',
|
||||
ttl,
|
||||
changetype: 'REPLACE',
|
||||
records: [{ content: ip, disabled: false }],
|
||||
},
|
||||
]);
|
||||
return fqdn;
|
||||
}
|
||||
|
||||
export async function deleteRecord(name, type = 'A') {
|
||||
const fqdn = name.endsWith('.') ? name : `${name}.${ZONE}`;
|
||||
await patchZone([{ name: fqdn, type, changetype: 'DELETE' }]);
|
||||
}
|
||||
24
server/promote-admin.mjs
Executable file
24
server/promote-admin.mjs
Executable file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
// Usage: node promote-admin.mjs <cdn>
|
||||
// (run from server/ directory, or set DB_PATH env var)
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.sqlite');
|
||||
|
||||
const cdn = process.argv[2];
|
||||
if (!cdn) {
|
||||
console.error('Usage: node promote-admin.mjs <cdn>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(dbPath);
|
||||
const row = db.prepare('SELECT cdn, role FROM employees WHERE cdn = ?').get(cdn);
|
||||
if (!row) {
|
||||
console.error(`No employee with cdn=${cdn}. Register via the UI first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
db.prepare("UPDATE employees SET role = 'admin' WHERE cdn = ?").run(cdn);
|
||||
console.log(`Promoted ${cdn} from ${row.role} → admin`);
|
||||
6
server/start.sh
Executable file
6
server/start.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/Cellar/simdjson/4.4.2/lib:/opt/homebrew/lib:/usr/local/lib:/usr/lib"
|
||||
export PORT="${PORT:-8800}"
|
||||
exec node index.js
|
||||
154
server/workers.js
Normal file
154
server/workers.js
Normal file
@ -0,0 +1,154 @@
|
||||
import { db, audit, setVars, getVars, tryClaim } from './db.js';
|
||||
const ACTIVE_PIDS_SQL = `
|
||||
SELECT DISTINCT process_instance_id AS pid FROM process_variables
|
||||
WHERE updated_at > datetime('now', '-7 days')
|
||||
`;
|
||||
|
||||
import * as zorro from './zorro.js';
|
||||
import * as pdns from './pdns.js';
|
||||
|
||||
const POLL_MS = Number(process.env.WORKER_POLL_MS || 2000);
|
||||
|
||||
const JOB_TYPES = [
|
||||
'employee.validateAndBuild',
|
||||
'domain.checkAvailability',
|
||||
'domain.autoApprove',
|
||||
'pdns.createRecord',
|
||||
'notification.sendActivation',
|
||||
'notification.sendRejection',
|
||||
'notification.sendUnavailable',
|
||||
];
|
||||
|
||||
function personalNameFromCdn(cdn) {
|
||||
return String(cdn || '').toLowerCase().trim().split('.')[0];
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
async 'employee.validateAndBuild'(vars) {
|
||||
const cdn = vars.employeeCdn;
|
||||
const emp = db.prepare('SELECT cdn, name, email FROM employees WHERE cdn = ?').get(cdn);
|
||||
if (!emp) return { isValid: false, finalDomainName: '', employeeFullName: '' };
|
||||
const name =
|
||||
vars.domainType === 'personal'
|
||||
? personalNameFromCdn(cdn)
|
||||
: String(vars.requestedName || '').toLowerCase().trim();
|
||||
return { isValid: !!name, finalDomainName: name, employeeFullName: emp.name };
|
||||
},
|
||||
|
||||
async 'domain.checkAvailability'(vars) {
|
||||
const taken = !!db.prepare('SELECT 1 FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||
return { isAvailable: !taken };
|
||||
},
|
||||
|
||||
async 'domain.autoApprove'(vars) {
|
||||
const existing = db.prepare('SELECT id FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||
if (existing) return { domainId: String(existing.id) };
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO domains (name, owner_cdn, type, status, zorro_instance_key) VALUES (?, ?, 'personal', 'active', ?)"
|
||||
)
|
||||
.run(vars.finalDomainName, vars.employeeCdn, vars.__processInstanceId);
|
||||
audit(vars.employeeCdn, 'domain.autoApprove', vars.finalDomainName, {});
|
||||
return { domainId: String(info.lastInsertRowid) };
|
||||
},
|
||||
|
||||
async 'pdns.createRecord'(vars) {
|
||||
const ip = vars.dnsTarget || process.env.DEFAULT_DNS_IP || '192.168.68.59';
|
||||
await pdns.createA(vars.finalDomainName, ip);
|
||||
const domain = db.prepare('SELECT id FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||
if (domain) {
|
||||
db.prepare(
|
||||
"UPDATE domains SET dns_record = ?, status = 'active', updated_at = datetime('now') WHERE id = ?"
|
||||
).run(ip, domain.id);
|
||||
} else {
|
||||
// project-approved path: INSERT now
|
||||
db.prepare(
|
||||
"INSERT INTO domains (name, owner_cdn, type, status, dns_record, purpose, zorro_instance_key) VALUES (?, ?, ?, 'active', ?, ?, ?)"
|
||||
).run(
|
||||
vars.finalDomainName,
|
||||
vars.employeeCdn,
|
||||
vars.domainType || 'project',
|
||||
ip,
|
||||
vars.purpose || null,
|
||||
vars.__processInstanceId
|
||||
);
|
||||
}
|
||||
audit(vars.employeeCdn, 'pdns.createRecord', vars.finalDomainName, { ip });
|
||||
return { dnsStatus: 'ok' };
|
||||
},
|
||||
|
||||
async 'notification.sendActivation'(vars) {
|
||||
console.log(`[email] ACTIVATION → ${vars.employeeEmail} : ${vars.finalDomainName}.kt активирован для ${vars.employeeFullName}`);
|
||||
return {};
|
||||
},
|
||||
|
||||
async 'notification.sendRejection'(vars) {
|
||||
console.log(`[email] REJECTION → ${vars.employeeEmail} : ${vars.finalDomainName}.kt отклонён (${vars.adminComment || ''})`);
|
||||
const d = db.prepare('SELECT id FROM domains WHERE name = ?').get(vars.finalDomainName);
|
||||
if (d) db.prepare("UPDATE domains SET status='inactive', updated_at=datetime('now') WHERE id=?").run(d.id);
|
||||
return {};
|
||||
},
|
||||
|
||||
async 'notification.sendUnavailable'(vars) {
|
||||
console.log(`[email] UNAVAILABLE → ${vars.employeeEmail} : имя ${vars.finalDomainName}.kt уже занято`);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
async function processOne(task) {
|
||||
const pid = task.processInstanceId;
|
||||
const storedVars = getVars(pid);
|
||||
if (!Object.keys(storedVars).length) {
|
||||
// foreign process instance (we didn't start it) — skip silently so we don't crash
|
||||
return;
|
||||
}
|
||||
const vars = { ...storedVars, __processInstanceId: pid };
|
||||
const handler = handlers[task.job];
|
||||
if (!handler) {
|
||||
console.error(`[worker] no handler for job=${task.job}`);
|
||||
return;
|
||||
}
|
||||
let out;
|
||||
try {
|
||||
out = await handler(vars);
|
||||
} catch (e) {
|
||||
console.error(`[worker] ${task.job} ${task.id} FAILED:`, e.message);
|
||||
return; // leave task uncompleted → next poll will retry
|
||||
}
|
||||
if (out && Object.keys(out).length) setVars(pid, out);
|
||||
try {
|
||||
await zorro.completeServiceTask(task.id, out || {});
|
||||
console.log(`[worker] ${task.job} ${task.id} completed → ${JSON.stringify(out)}`);
|
||||
} catch (e) {
|
||||
console.error(`[worker] complete ${task.id} FAILED:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOnce() {
|
||||
const pids = db.prepare(ACTIVE_PIDS_SQL).all().map(r => r.pid);
|
||||
for (const pid of pids) {
|
||||
let tasks;
|
||||
try { tasks = await zorro.serviceTasksForInstance(pid); }
|
||||
catch (e) { console.error(`[worker] poll ${pid}:`, e.message); continue; }
|
||||
for (const task of tasks) {
|
||||
if (!handlers[task.job]) continue;
|
||||
if (!tryClaim(task.id)) continue;
|
||||
processOne(task).catch(e => console.error('[worker] unhandled', e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
export function startWorkers() {
|
||||
if (timer) return;
|
||||
const loop = async () => {
|
||||
try { await pollOnce(); } catch (e) { console.error('[worker] pollOnce:', e); }
|
||||
timer = setTimeout(loop, POLL_MS);
|
||||
};
|
||||
loop();
|
||||
console.log(`[worker] started, poll ${POLL_MS}ms, jobs: ${JOB_TYPES.join(', ')}`);
|
||||
}
|
||||
|
||||
export function stopWorkers() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
}
|
||||
63
server/zorro.js
Normal file
63
server/zorro.js
Normal file
@ -0,0 +1,63 @@
|
||||
const BASE = process.env.ZORRO_URL || 'http://10.9.0.12:9092';
|
||||
|
||||
async function req(method, path, body) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`Zorro ${method} ${path} → ${res.status}: ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
function toZorroVars(obj) {
|
||||
const out = [];
|
||||
for (const [name, v] of Object.entries(obj)) {
|
||||
if (v == null) continue;
|
||||
const type = typeof v === 'boolean' ? 'BOOLEAN' : 'STRING';
|
||||
out.push({ name, value: String(v), type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function startProcess(key, version, vars) {
|
||||
const res = await req('POST', '/process-instances', {
|
||||
processDefinitionKey: key,
|
||||
processDefinitionVersion: version,
|
||||
variables: toZorroVars(vars),
|
||||
});
|
||||
return res.id;
|
||||
}
|
||||
|
||||
export async function serviceTasksForInstance(processInstanceId) {
|
||||
const qs = new URLSearchParams({ processInstanceId, pageSize: '50' });
|
||||
const res = await req('GET', `/service-tasks?${qs}`);
|
||||
// completed=false filter is ignored by Zorro — filter client-side
|
||||
return (res.data || []).filter(t => !t.completedAt);
|
||||
}
|
||||
|
||||
export async function completeServiceTask(id, vars) {
|
||||
return req('POST', `/service-tasks/${id}/complete`, { variables: toZorroVars(vars) });
|
||||
}
|
||||
|
||||
export async function listUserTasks({ candidateGroup, assigned = false, completed = false } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (candidateGroup) qs.set('candidateGroup', candidateGroup);
|
||||
qs.set('assigned', String(assigned));
|
||||
qs.set('completed', String(completed));
|
||||
qs.set('pageSize', '50');
|
||||
const res = await req('GET', `/user-tasks?${qs}`);
|
||||
return res.data || [];
|
||||
}
|
||||
|
||||
export async function completeUserTask(id, vars) {
|
||||
return req('POST', `/user-tasks/${id}/complete`, { variables: toZorroVars(vars) });
|
||||
}
|
||||
|
||||
export async function listIncidents(processInstanceId) {
|
||||
const qs = new URLSearchParams({ pageSize: '20' });
|
||||
if (processInstanceId) qs.set('processInstanceId', processInstanceId);
|
||||
const res = await req('GET', `/incidents?${qs}`);
|
||||
return res.data || [];
|
||||
}
|
||||
86
summary.md
Normal file
86
summary.md
Normal file
@ -0,0 +1,86 @@
|
||||
# domain.kt — Summary
|
||||
|
||||
Внутренний MVP-проект Казахтелеком. Цель — обкатать создание и исполнение бизнес-процессов внутри компании с помощью BPMN-движка Zorro.
|
||||
|
||||
**Владелец:** Аскар Тулеев
|
||||
**Статус:** MVP / в разработке
|
||||
**Репозиторий движка:** `git.itte.kz/Dzhunusov.Tr/zorrobpm`
|
||||
|
||||
---
|
||||
|
||||
## Что это
|
||||
|
||||
Корпоративный регистратор доменов второго уровня в зоне `.kt`. Каждый сотрудник получает домен на основе своего CDN (корпоративного идентификатора) — например, CDN `tuleev.a` → домен `tuleev.kt`. Дополнительно можно зарегистрировать произвольные проектные домены.
|
||||
|
||||
Реальная задача — не столько сам регистратор, сколько рабочий пример того, как веб-интерфейс, BPMN-процесс и Zorro работают вместе в корпоративной среде.
|
||||
|
||||
---
|
||||
|
||||
## Что уже есть
|
||||
|
||||
| Файл | Описание |
|
||||
|---|---|
|
||||
| `index.html` | Публичный лендинг: поиск домена, как это работает |
|
||||
| `login.html` | Вход (CDN + пароль) и первичная регистрация |
|
||||
| `dashboard.html` | Кабинет сотрудника: мои домены, регистрация, смена имени |
|
||||
| `admin.html` | Панель IT-администратора: реестр, очередь заявок, конфликты |
|
||||
| `domain-registration.bpmn` | Два исполняемых BPMN-процесса для Zorro |
|
||||
|
||||
---
|
||||
|
||||
## Как работает регистрация домена
|
||||
|
||||
```
|
||||
Сотрудник вводит CDN на login.html
|
||||
↓
|
||||
dashboard.html → форма регистрации домена
|
||||
↓
|
||||
POST на бэкенд → запуск процесса в Zorro
|
||||
↓
|
||||
Zorro исполняет domain-registration.bpmn:
|
||||
- валидирует CDN
|
||||
- формирует имя домена
|
||||
- проверяет доступность
|
||||
- персональный домен → авто-активация
|
||||
- проектный домен → User Task для IT-администратора (admin.html)
|
||||
- при конфликте → отдельный User Task разрешения
|
||||
↓
|
||||
DNS-провизия → email-уведомление → домен активен
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Домены и логика формирования
|
||||
|
||||
| CDN | Тип | Итоговый домен |
|
||||
|---|---|---|
|
||||
| `tuleev.a` | Персональный | `tuleev.kt` |
|
||||
| `abenova.g` | Персональный | `abenova.kt` |
|
||||
| `tuleev.a` (вручную) | Проектный | `devops-team.kt` |
|
||||
|
||||
Один сотрудник может иметь несколько доменов (личный + проектные).
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Бэкенд-стек (Java/Spring, Node.js, Go — не определено)
|
||||
- DNS-сервер (BIND9, PowerDNS — не определено)
|
||||
- База данных реестра (PostgreSQL, другое — не определено)
|
||||
- Детали деплоя Zorro на Docker
|
||||
- Детали API Zorro для деплоя BPMN и запуска процессов
|
||||
- Аутентификация: сейчас CDN + пароль, в будущем — возможно SSO/LDAP
|
||||
|
||||
---
|
||||
|
||||
## Что дальше
|
||||
|
||||
1. Развернуть Zorro локально (`zorrobpm`)
|
||||
2. Загрузить `domain-registration.bpmn` в Zorro
|
||||
3. Реализовать job workers для каждого service task
|
||||
4. Подключить фронт к бэкенду
|
||||
5. Задеплоить в Docker
|
||||
|
||||
---
|
||||
|
||||
*Подробности — в `prd.md`, `trd.md`, `zorro.md`.*
|
||||
321
trd.md
Normal file
321
trd.md
Normal file
@ -0,0 +1,321 @@
|
||||
# TRD — domain.kt
|
||||
|
||||
**Тип:** MVP
|
||||
**Владелец:** Аскар Тулеев
|
||||
**Аудитория документа:** разработчики, системные аналитики
|
||||
|
||||
---
|
||||
|
||||
## Обзор архитектуры
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Браузер │
|
||||
│ index.html │ login.html │ dashboard.html │ admin.html │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ HTTP / REST
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Backend API │
|
||||
│ POST /domains/check POST /domains/register │
|
||||
│ POST /auth/login GET /domains │
|
||||
│ POST /auth/register PATCH /domains/:id │
|
||||
│ GET /admin/domains POST /admin/domains/:id/approve │
|
||||
│ GET /admin/conflicts POST /admin/domains/:id/reject │
|
||||
└──────────────┬───────────────────────────┬───────────────────┘
|
||||
│ REST (start process, │ SQL
|
||||
│ complete user task) ▼
|
||||
▼ ┌────────────────┐
|
||||
┌──────────────────────┐ │ База данных │
|
||||
│ Zorro │ │ (PostgreSQL) │
|
||||
│ (BPMN-движок) │ │ │
|
||||
│ domain-registration │ │ employees │
|
||||
│ .bpmn │◄────────►│ domains │
|
||||
│ │ job │ requests │
|
||||
└──────────────┬───────┘ workers │ audit_log │
|
||||
│ └────────────────┘
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Job Workers │
|
||||
│ (отдельный сервис │
|
||||
│ или встроены в │
|
||||
│ Backend API) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Компоненты
|
||||
|
||||
### Фронтенд
|
||||
|
||||
Статические HTML-файлы, Vanilla JS. Никаких фреймворков в MVP.
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `index.html` | Публичный лендинг, поиск домена |
|
||||
| `login.html` | Вход и регистрация сотрудника |
|
||||
| `dashboard.html` | Кабинет сотрудника, SPA-роутинг через `showPage()` |
|
||||
| `admin.html` | Панель IT-администратора |
|
||||
|
||||
Все страницы используют общий CSS-дизайн: CSS-переменные (`--blue`, `--bg`, `--border` и т.д.), шрифт Inter/system-ui.
|
||||
|
||||
### Backend API
|
||||
|
||||
Стек не определён. Варианты: Java/Spring Boot, Node.js/Express, Go/Gin.
|
||||
|
||||
Обязанности:
|
||||
- Аутентификация (CDN + пароль, хэш bcrypt)
|
||||
- CRUD операции с доменами
|
||||
- Запуск процессов в Zorro через REST API
|
||||
- Завершение User Task в Zorro при действии администратора
|
||||
- Подписка на job workers Zorro (polling loop)
|
||||
|
||||
### Zorro (BPMN-движок)
|
||||
|
||||
Внутренний движок на базе `zorrobpm`. Исполняет `domain-registration.bpmn`.
|
||||
|
||||
Подробнее — в `zorro.md`.
|
||||
|
||||
### База данных
|
||||
|
||||
Предполагается PostgreSQL. Схема:
|
||||
|
||||
```sql
|
||||
-- Сотрудники
|
||||
CREATE TABLE employees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cdn VARCHAR(64) UNIQUE NOT NULL, -- 'tuleev.a'
|
||||
name VARCHAR(128) NOT NULL,
|
||||
email VARCHAR(256) UNIQUE NOT NULL,
|
||||
department VARCHAR(128),
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
role VARCHAR(16) DEFAULT 'employee', -- 'employee' | 'admin'
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Реестр доменов
|
||||
CREATE TABLE domains (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) UNIQUE NOT NULL, -- 'tuleev' (без .kt)
|
||||
fqdn VARCHAR(256) GENERATED ALWAYS AS (name || '.kt') STORED,
|
||||
owner_cdn VARCHAR(64) REFERENCES employees(cdn),
|
||||
type VARCHAR(16) NOT NULL, -- 'personal' | 'project'
|
||||
status VARCHAR(16) NOT NULL, -- 'active' | 'pending' | 'conflict' | 'inactive'
|
||||
dns_record VARCHAR(512),
|
||||
purpose TEXT,
|
||||
zorro_instance_key BIGINT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- История заявок
|
||||
CREATE TABLE requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain_id INT REFERENCES domains(id),
|
||||
type VARCHAR(32) NOT NULL, -- 'register' | 'change' | 'deactivate'
|
||||
status VARCHAR(16) NOT NULL, -- 'pending' | 'approved' | 'rejected'
|
||||
initiated_by VARCHAR(64) REFERENCES employees(cdn),
|
||||
resolved_by VARCHAR(64) REFERENCES employees(cdn),
|
||||
admin_comment TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Журнал действий администратора
|
||||
CREATE TABLE audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
actor_cdn VARCHAR(64) REFERENCES employees(cdn),
|
||||
action VARCHAR(128) NOT NULL,
|
||||
target VARCHAR(256),
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API-контракты
|
||||
|
||||
### Аутентификация
|
||||
|
||||
```
|
||||
POST /auth/login
|
||||
Body: { "cdn": "tuleev.a", "password": "..." }
|
||||
Response: { "token": "jwt...", "role": "employee" }
|
||||
|
||||
POST /auth/register
|
||||
Body: { "cdn": "tuleev.a", "name": "Аскар Тулеев", "email": "...", "department": "...", "password": "..." }
|
||||
Response: { "token": "jwt...", "previewDomain": "tuleev.kt" }
|
||||
```
|
||||
|
||||
### Домены
|
||||
|
||||
```
|
||||
GET /domains/check?name=devops-team
|
||||
Response: { "available": true }
|
||||
| { "available": false, "suggestions": ["devops-team2.kt", "devops.kt"] }
|
||||
|
||||
GET /domains?cdn=tuleev.a
|
||||
Response: [ { "id": 1, "fqdn": "tuleev.kt", "type": "personal", "status": "active", ... } ]
|
||||
|
||||
POST /domains/register
|
||||
Body: { "name": "devops-team", "type": "project", "purpose": "...", "dns": "10.0.0.1" }
|
||||
Response: { "domainId": 42, "status": "pending", "zorroInstanceKey": 2251799813685277 }
|
||||
|
||||
PATCH /domains/:id/rename
|
||||
Body: { "newName": "dev-team" }
|
||||
Response: { "status": "pending" }
|
||||
```
|
||||
|
||||
### Администратор
|
||||
|
||||
```
|
||||
GET /admin/domains?status=pending&type=project&search=dev
|
||||
Response: [ { ...domain, ownerName, ownerCdn } ]
|
||||
|
||||
POST /admin/domains/:id/approve
|
||||
Body: { "comment": "" }
|
||||
|
||||
POST /admin/domains/:id/reject
|
||||
Body: { "comment": "Имя зарезервировано" }
|
||||
|
||||
POST /admin/domains/:id/reassign
|
||||
Body: { "suggestedAlternative": "tuleev-d.kt" }
|
||||
|
||||
GET /admin/conflicts
|
||||
Response: [ { "domain": "tuleev.kt", "claimants": [ {...employee1}, {...employee2} ] } ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Контракты Job Workers
|
||||
|
||||
Каждый service task в BPMN исполняется job worker'ом. Worker подписывается на тип задачи, берёт job, выполняет логику, завершает job с переменными или ошибкой.
|
||||
|
||||
| Job Type | Входные переменные | Выходные переменные | Логика |
|
||||
|---|---|---|---|
|
||||
| `employee.validateAndBuild` | `cdn`, `domainType`, `requestedName` | `isValid`, `finalDomainName`, `employeeFullName` | Проверить CDN в БД; personal → surname из CDN, project → requestedName |
|
||||
| `domain.checkAvailability` | `domainName` | `isAvailable` | Проверить таблицу `domains` |
|
||||
| `domain.autoApprove` | `domainName`, `cdn` | `domainId` | Записать домен в БД, статус `active` |
|
||||
| `pdns.createRecord` | `domainName`, `recordType`, `target` | `dnsStatus` | PATCH на PowerDNS API (`127.0.0.1:8081`): создать A/CNAME запись `domainName.kt.` в зоне `kt.` |
|
||||
| `notification.sendActivation` | `email`, `fullName`, `domainName` | — | Email: домен активирован |
|
||||
| `notification.sendRejection` | `email`, `domainName`, `reason` | — | Email: заявка отклонена |
|
||||
| `notification.sendUnavailable` | `email`, `domainName` | — | Email: имя занято |
|
||||
|
||||
---
|
||||
|
||||
## PowerDNS — интеграция
|
||||
|
||||
DNS-сервер: **PowerDNS Authoritative** на Mac Studio (`192.168.68.59`).
|
||||
|
||||
| Компонент | Адрес | Назначение |
|
||||
|---|---|---|
|
||||
| REST API | `http://127.0.0.1:8081/api/v1/servers/localhost` | управление записями (только с localhost) |
|
||||
| Auth DNS | `127.0.0.1:5300` | авторитативные ответы по зоне `.kt` |
|
||||
| Recursor | `0.0.0.0:53` | резолвер для всего LAN |
|
||||
|
||||
API-ключ хранится в `~/.pdns-api-key` на Mac Studio.
|
||||
|
||||
Job worker `pdns.createRecord` выполняет PATCH-запрос:
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://127.0.0.1:8081/api/v1/servers/localhost/zones/kt. \
|
||||
-H "X-API-Key: <key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"rrsets": [{
|
||||
"name": "tuleev.kt.",
|
||||
"type": "A",
|
||||
"ttl": 60,
|
||||
"changetype": "REPLACE",
|
||||
"records": [{"content": "10.0.0.50", "disabled": false}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
Важно: имя домена всегда в FQDN-формате с точкой в конце (`tuleev.kt.`).
|
||||
|
||||
Поскольку API доступен только с localhost Mac Studio, job worker должен запускаться на той же машине (или через SSH-туннель).
|
||||
|
||||
---
|
||||
|
||||
## Интеграция фронтенда с Zorro
|
||||
|
||||
Фронт не общается с Zorro напрямую. Схема:
|
||||
|
||||
```
|
||||
dashboard.html
|
||||
│ POST /domains/register
|
||||
▼
|
||||
Backend API
|
||||
│ POST http://10.9.0.12:8080/api/processes/kt_domain_registration/start
|
||||
│ Body: { variables: { cdn: "tuleev.a", domainType: "project", ... } }
|
||||
▼
|
||||
Zorro (http://10.9.0.12:8080)
|
||||
│ instanceKey: 2251799813685277
|
||||
▼
|
||||
Backend API сохраняет instanceKey в БД → polling статуса через /domains/:id
|
||||
```
|
||||
|
||||
Когда администратор нажимает "Одобрить" в `admin.html`:
|
||||
|
||||
```
|
||||
admin.html
|
||||
│ POST /admin/domains/:id/approve
|
||||
▼
|
||||
Backend API
|
||||
│ POST /api/zorro/jobs/complete (или аналогичный endpoint Zorro)
|
||||
│ Body: { jobKey: ..., variables: { adminDecision: "APPROVE" } }
|
||||
▼
|
||||
Zorro продолжает процесс
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker (набросок)
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: domain_kt
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: secret
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
# Zorro уже запущен на http://10.9.0.12:8080/
|
||||
# Для локальной разработки можно поднять из git.itte.kz/Dzhunusov.Tr/zorrobpm
|
||||
|
||||
backend:
|
||||
image: domain-kt-api:latest
|
||||
environment:
|
||||
DB_URL: postgres://app:secret@db:5432/domain_kt
|
||||
ZORRO_URL: http://10.9.0.12:8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- db
|
||||
- zorro
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
Фронт раздаётся как статика (nginx или встроен в backend).
|
||||
|
||||
---
|
||||
|
||||
## Открытые технические вопросы
|
||||
|
||||
1. **Бэкенд-стек** — Java/Spring Boot, Node.js или Go?
|
||||
2. **Zorro API** — точные endpoint'ы для старта процесса, завершения User Task, опроса статуса
|
||||
3. **DNS** — в MVP DNS не реальный; нужно определить, будет ли BIND9/PowerDNS на следующем этапе
|
||||
4. **Email** — SMTP-сервер для уведомлений (корпоративный или заглушка)
|
||||
5. **JWT или сессии** — механизм авторизации для API
|
||||
6. **CDN-справочник** — откуда берётся список валидных CDN (отдельная таблица, Excel-импорт, AD?)
|
||||
7. **Лимит доменов** — дефолт "без ограничений" в MVP, но архитектурно нужно поле в настройках
|
||||
8. **Зарезервированные имена** — список (admin, mail, vpn, www...) хранится в БД или в конфиге
|
||||
598
zorro.md
Normal file
598
zorro.md
Normal file
@ -0,0 +1,598 @@
|
||||
# ZorroBPM Development Guide
|
||||
|
||||
Единый справочник для разработки процессов на ZorroBPM.
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Архитектура](#архитектура)
|
||||
- [Окружения](#окружения)
|
||||
- [Локальный запуск](#локальный-запуск)
|
||||
- [Docker Compose — полный стек](#docker-compose--полный-стек)
|
||||
- [Docker Compose — только фронт (dev)](#docker-compose--только-фронт-dev)
|
||||
- [Docker Compose — фронт + воркеры](#docker-compose--фронт--воркеры)
|
||||
- [API Reference](#api-reference)
|
||||
- [RabbitMQ и внешние воркеры](#rabbitmq-и-внешние-воркеры)
|
||||
- [Разработка процессов](#разработка-процессов)
|
||||
- [Тестирование](#тестирование)
|
||||
- [Примеры](#примеры)
|
||||
- [Известные ограничения](#известные-ограничения)
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ UI │────▶│ REST API │────▶│ PostgreSQL │
|
||||
│ (nginx) │ │ (Spring Boot)│ │ │
|
||||
└──────────┘ └──────┬───────┘ └────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌────────────────┐
|
||||
│ RabbitMQ │◀───▶│ External │
|
||||
│ │ │ Workers (опц.) │
|
||||
└──────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
| Компонент | Образ | Описание |
|
||||
|-----------|-------|----------|
|
||||
| **api** | `zorrodev/zorrobpm-rest` | BPM движок. Управляет определениями, инстансами, задачами, инцидентами |
|
||||
| **ui** | `zorrodev/zorrobpm-ui` | Nginx SPA — мониторинг процессов, задач, деплоев |
|
||||
| **db** | `postgres:18` | Хранит определения, инстансы, переменные, инциденты |
|
||||
| **rabbitmq** | `rabbitmq:4.0.7-management` | Очереди для serviceTask и событий жизненного цикла |
|
||||
|
||||
---
|
||||
|
||||
## Окружения
|
||||
|
||||
| Окружение | URL | Примечание |
|
||||
|-----------|-----|------------|
|
||||
| **LOCAL** | http://localhost:9092 | Docker Compose |
|
||||
| **PROD** | https://api.bpms.telecom.kz | Через VPN |
|
||||
| **DEV Studio** | https://studio.zorrobpm.dev | Визуальный редактор |
|
||||
| **DEV Station** | https://station.zorrobpm.dev | Мониторинг |
|
||||
| **DEMO (m11s)** | https://api.m11s.zorrobpm.dev | Микросервисная демо-среда |
|
||||
|
||||
Swagger UI:
|
||||
- Local: http://localhost:9092/swagger-ui/index.html
|
||||
- Demo: https://api.m11s.zorrobpm.dev/swagger-ui/index.html
|
||||
|
||||
Аутентификация: не требуется (dev-окружение).
|
||||
|
||||
---
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
# Запуск полного стека
|
||||
docker compose -f docker-compose.yaml --env-file .env up -d
|
||||
|
||||
# Логи API
|
||||
docker compose logs api -f
|
||||
|
||||
# Остановка
|
||||
docker compose down
|
||||
|
||||
# Остановка + удаление данных
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose — полный стек
|
||||
|
||||
Полный стек для самостоятельного запуска движка. Используй только если нужен свой локальный инстанс BPM.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yaml — полный стек ZorroBPM
|
||||
services:
|
||||
|
||||
api:
|
||||
image: zorrodev/zorrobpm-rest
|
||||
environment:
|
||||
- DB_URL=jdbc:postgresql://db/${DB_NAME}
|
||||
- DB_USERNAME=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- APP_FILES_DIR=/files
|
||||
- RABBITMQ_HOST=rabbitmq
|
||||
- RABBITMQ_USERNAME=${RABBITMQ_USER}
|
||||
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
|
||||
- RABBITMQ_PORT=5672
|
||||
volumes:
|
||||
- files:/files
|
||||
ports:
|
||||
- 9092:80
|
||||
|
||||
db:
|
||||
image: postgres:18
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- db:/var/lib/postgresql/18/docker
|
||||
ports:
|
||||
- "0.0.0.0:5435:5432"
|
||||
|
||||
ui:
|
||||
image: zorrodev/zorrobpm-ui
|
||||
ports:
|
||||
- 9091:80
|
||||
environment:
|
||||
- ZORROBPM_API_URL=http://zorrobpm.local
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.0.7-management
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
volumes:
|
||||
- rabbitmq-data:/var/lib/rabbitmq
|
||||
|
||||
volumes:
|
||||
rabbitmq-data:
|
||||
db:
|
||||
files:
|
||||
```
|
||||
|
||||
`.env`:
|
||||
```env
|
||||
DB_NAME=zorrobpm
|
||||
DB_USER=zorrobpm
|
||||
DB_PASSWORD=zorrobpm
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose — только фронт (dev)
|
||||
|
||||
**Для разработки процессов без локального движка.** Подключается к удалённому API (demo, dev или prod). Не нужны PostgreSQL, RabbitMQ, API — только UI.
|
||||
|
||||
```yaml
|
||||
# docker-compose.front.yaml — фронт без движка
|
||||
services:
|
||||
|
||||
ui:
|
||||
image: zorrodev/zorrobpm-ui
|
||||
ports:
|
||||
- 9091:80
|
||||
environment:
|
||||
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||
```
|
||||
|
||||
`.env.front`:
|
||||
```env
|
||||
# Указать один из:
|
||||
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev # demo
|
||||
# ZORROBPM_API_URL=https://api.bpms.telecom.kz # prod (VPN)
|
||||
# ZORROBPM_API_URL=http://localhost:9092 # local
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.front.yaml --env-file .env.front up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose — фронт + воркеры
|
||||
|
||||
**Когда процесс содержит serviceTask с внешними воркерами.** Фронт для мониторинга + RabbitMQ для получения задач + твой воркер.
|
||||
|
||||
```yaml
|
||||
# docker-compose.workers.yaml — фронт + RabbitMQ воркеры
|
||||
services:
|
||||
|
||||
ui:
|
||||
image: zorrodev/zorrobpm-ui
|
||||
ports:
|
||||
- 9091:80
|
||||
environment:
|
||||
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||
|
||||
# Пример Java-воркера
|
||||
worker:
|
||||
build: ./worker
|
||||
environment:
|
||||
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||
- RABBITMQ_HOST=${RABBITMQ_HOST}
|
||||
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
|
||||
- RABBITMQ_USERNAME=${RABBITMQ_USER}
|
||||
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
|
||||
depends_on:
|
||||
- ui
|
||||
```
|
||||
|
||||
`.env.workers`:
|
||||
```env
|
||||
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev
|
||||
|
||||
# RabbitMQ — указать хост удалённого брокера, если он открыт,
|
||||
# или поднять локально (добавить rabbitmq сервис)
|
||||
RABBITMQ_HOST=rabbitmq.m11s.zorrobpm.dev
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
```
|
||||
|
||||
> **Примечание:** если удалённый RabbitMQ недоступен извне, добавь сервис `rabbitmq` из полного стека и настрой API на использование этого брокера.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
Base URL: `http://localhost:9092` (local) или `https://api.m11s.zorrobpm.dev` (demo)
|
||||
|
||||
### Process Definitions
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `POST` | `/process-definitions` | Деплой BPMN |
|
||||
| `GET` | `/process-definitions` | Список (с пагинацией) |
|
||||
| `GET` | `/process-definitions/{id}` | По ID |
|
||||
| `GET` | `/process-definitions/{id}/xml` | BPMN XML |
|
||||
|
||||
**Деплой:**
|
||||
```http
|
||||
POST /process-definitions
|
||||
Content-Type: application/json
|
||||
|
||||
{ "bpmn": "<bpmn:definitions>...</bpmn:definitions>" }
|
||||
```
|
||||
|
||||
Ответ — `ProcessDefinition`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"key": "string",
|
||||
"version": 1,
|
||||
"name": "string",
|
||||
"sha256": "string",
|
||||
"createdAt": "date-time",
|
||||
"startFormKey": "string"
|
||||
}
|
||||
```
|
||||
|
||||
Query params: `pageIndex`, `pageSize`, `processDefinitionKey`, `processDefinitionVersion`, `latestVersionOnly`
|
||||
|
||||
---
|
||||
|
||||
### Process Instances
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `POST` | `/process-instances` | Запуск инстанса |
|
||||
| `GET` | `/process-instances` | Список (с пагинацией) |
|
||||
|
||||
**Запуск:**
|
||||
```http
|
||||
POST /process-instances
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"processDefinitionId": "uuid",
|
||||
"processDefinitionKey": "string",
|
||||
"processDefinitionVersion": 1,
|
||||
"variables": [
|
||||
{ "name": "var1", "value": "val1", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Можно указать `processDefinitionId` ИЛИ `processDefinitionKey` + `processDefinitionVersion`.
|
||||
|
||||
Ответ: `{ "id": "uuid" }`
|
||||
|
||||
Query params: `id`, `pageIndex`, `pageSize`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`
|
||||
|
||||
Объект `ProcessInstance`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"parentActivityId": "uuid",
|
||||
"processDefinitionId": "uuid",
|
||||
"startedAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Service Tasks
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/service-tasks` | Список сервисных задач |
|
||||
| `POST` | `/service-tasks/{id}/complete` | Завершить задачу |
|
||||
|
||||
Query params: `id`, `jobType`, `completed`, `pageIndex`, `pageSize`, `processInstanceId`
|
||||
|
||||
Объект `ServiceTask`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"code": "string",
|
||||
"name": "string",
|
||||
"processInstanceId": "uuid",
|
||||
"processDefinitionId": "uuid",
|
||||
"job": "string",
|
||||
"createdAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Завершение:**
|
||||
```http
|
||||
POST /service-tasks/{id}/complete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"variables": [
|
||||
{ "name": "result", "value": "done", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### User Tasks
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/user-tasks` | Список пользовательских задач |
|
||||
| `POST` | `/user-tasks/{id}/complete` | Завершить задачу |
|
||||
|
||||
Query params: `id`, `assignee`, `candidateGroup`, `candidateUser`, `completed`, `assigned`, `pageIndex`, `pageSize`, `processInstanceId`
|
||||
|
||||
Объект `UserTask`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"code": "string",
|
||||
"name": "string",
|
||||
"processInstanceId": "uuid",
|
||||
"processDefinitionId": "uuid",
|
||||
"formKey": "string",
|
||||
"createdAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Завершение:**
|
||||
```http
|
||||
POST /user-tasks/{id}/complete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"variables": [
|
||||
{ "name": "action", "value": "approve", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Incidents
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/incidents` | Список инцидентов |
|
||||
| `POST` | `/incidents/{id}/resolve` | Разрешить инцидент |
|
||||
|
||||
Query params: `id`, `processInstanceId`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`, `bpmnElementId`, `pageIndex`, `pageSize`
|
||||
|
||||
Объект `Incident`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"activityId": "uuid",
|
||||
"message": "string",
|
||||
"createdAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Разрешение:**
|
||||
```http
|
||||
POST /incidents/{id}/resolve
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "uuid",
|
||||
"variables": [
|
||||
{ "name": "fix", "value": "retried", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Типы переменных
|
||||
|
||||
`STRING`, `UUID`, `LONG`, `BOOLEAN`
|
||||
|
||||
### Формат пагинации
|
||||
|
||||
Все list-эндпоинты возвращают:
|
||||
```json
|
||||
{
|
||||
"pageIndex": 0,
|
||||
"pageSize": 20,
|
||||
"totalElements": 100,
|
||||
"data": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RabbitMQ и внешние воркеры
|
||||
|
||||
### Очереди
|
||||
|
||||
| Очередь | Описание |
|
||||
|---------|----------|
|
||||
| `zorrobpm.engine.definition-events` | Жизненный цикл определений (deployed, updated) |
|
||||
| `zorrobpm.engine.runtime-events` | Жизненный цикл инстансов (created, completed) и задач |
|
||||
| `zorrobpm.jobs.<job_type>` | Очередь для каждого типа serviceTask |
|
||||
|
||||
### Как это работает
|
||||
|
||||
Когда BPMN `serviceTask` с атрибутом `job="srv_child_work"` достигается движком:
|
||||
1. Движок публикует сообщение в очередь `zorrobpm.jobs.srv_child_work`
|
||||
2. Внешний воркер читает из этой очереди
|
||||
3. Воркер выполняет работу
|
||||
4. Воркер вызывает `POST /service-tasks/{id}/complete`
|
||||
|
||||
### Пример воркера (Java + Spring AMQP)
|
||||
|
||||
```java
|
||||
@RabbitListener(queues = "zorrobpm.jobs.srv_child_work")
|
||||
public void handleChildWork(ServiceTaskMessage msg) {
|
||||
// выполнить работу...
|
||||
api.completeServiceTask(msg.getTaskId());
|
||||
}
|
||||
```
|
||||
|
||||
### Инспекция очередей
|
||||
|
||||
```bash
|
||||
docker exec zorrobpm-rabbitmq-1 rabbitmqctl list_queues name messages consumers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Разработка процессов
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
1. **Нарисуй BPMN** — в Studio (https://studio.zorrobpm.dev) или любом BPMN-редакторе
|
||||
2. **Деплой** — `POST /process-definitions` с BPMN XML
|
||||
3. **Запусти** — `POST /process-instances` с ключом или ID определения
|
||||
4. **Обрабатывай задачи**:
|
||||
- `serviceTask` — воркер через RabbitMQ или ручной `POST /service-tasks/{id}/complete`
|
||||
- `userTask` — UI или ручной `POST /user-tasks/{id}/complete`
|
||||
|
||||
### Выбор docker-compose
|
||||
|
||||
```
|
||||
Нужен ли локальный BPM движок?
|
||||
├── Да → docker-compose.yaml (полный стек)
|
||||
└── Нет, использую удалённый API
|
||||
├── Есть serviceTask с воркерами? → docker-compose.workers.yaml
|
||||
└── Только UI/мониторинг? → docker-compose.front.yaml
|
||||
```
|
||||
|
||||
### Структура проекта (рекомендуемая)
|
||||
|
||||
```
|
||||
my-process/
|
||||
├── docker-compose.yaml # выбери нужный вариант
|
||||
├── .env
|
||||
├── bpmn/
|
||||
│ ├── myProcess.bpmn
|
||||
│ └── childProcess.bpmn
|
||||
├── worker/ # если есть serviceTask
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
└── tests/
|
||||
└── e2e/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Dev-контейнер
|
||||
|
||||
```yaml
|
||||
# docker-compose.dev.yaml
|
||||
services:
|
||||
app:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: examples-dev
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- maven-cache:/root/.m2
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "5005:5005" # remote debug
|
||||
command: sleep infinity
|
||||
|
||||
volumes:
|
||||
maven-cache:
|
||||
```
|
||||
|
||||
### Режимы тестирования
|
||||
|
||||
| Режим | Команда | Зависимости | Описание |
|
||||
|-------|---------|-------------|----------|
|
||||
| **embedded** | `./test.sh` | Только dev контейнер | Unit тесты, встроенный движок + H2 |
|
||||
| **local** | `./test.sh local` | Dev + ZorroBPM docker | E2E против `localhost:9092` |
|
||||
| **remote** | `./test.sh remote` | Dev + интернет | E2E против `api.m11s.zorrobpm.dev` |
|
||||
| **custom** | `./test.sh http://my-server:8080` | Dev + сервер | E2E против произвольного URL |
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
./test.sh # embedded
|
||||
./test.sh local # E2E local
|
||||
./test.sh remote # E2E remote
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Примеры
|
||||
|
||||
### call-activity — вызов дочернего процесса
|
||||
|
||||
**Родительский процесс** (`parentProcess.bpmn`):
|
||||
```
|
||||
[Start] → [CallActivity: childProcess] → [End]
|
||||
```
|
||||
|
||||
**Дочерний процесс** (`childProcess.bpmn`):
|
||||
```
|
||||
[Start] → [ServiceTask: srv_child_work] → [End]
|
||||
```
|
||||
|
||||
**Поток выполнения:**
|
||||
1. Деплой обоих BPMN
|
||||
2. Запуск родительского — он входит в `callActivity`, создаёт дочерний инстанс
|
||||
3. Дочерний работает в **отдельном processInstance**
|
||||
4. Завершение serviceTask в дочернем → callActivity возобновляется → родитель завершается
|
||||
|
||||
**Файлы:**
|
||||
```
|
||||
src/main/resources/examples/call-activity/
|
||||
parentProcess.bpmn
|
||||
childProcess.bpmn
|
||||
|
||||
src/test/java/.../examples/callactivity/
|
||||
CallActivityTest.java # unit (embedded)
|
||||
|
||||
src/test/java/.../e2e/
|
||||
CallActivityE2ETest.java # E2E
|
||||
ZorroWebAPI.java # HTTP клиент
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
### Нет parentProcessInstanceId
|
||||
|
||||
API не поддерживает фильтр `parentProcessInstanceId` в `ProcessInstance`. Поле `parentActivityId` — это UUID активити (callActivity элемента), а не ID родительского инстанса.
|
||||
|
||||
**Нельзя напрямую запросить:** "дай все дочерние процессы родителя X".
|
||||
|
||||
**Workaround:** найти последний инстанс с `parentActivityId != null` на последней странице. Работает для последовательных тестов, ненадёжно при параллельных.
|
||||
|
||||
**Желаемое улучшение API:**
|
||||
```
|
||||
GET /process-instances?parentProcessInstanceId={uuid}
|
||||
```
|
||||
|
||||
Плюс добавить поле `parentProcessInstanceId` в объект `ProcessInstance`.
|
||||
Reference in New Issue
Block a user