feat: NetAdmin DNS & Mail console — version initiale
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Secrets & certs
|
||||
*.key
|
||||
*.pem
|
||||
certs/
|
||||
|
||||
# DB
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,368 @@
|
||||
# NetAdmin — Console DNS & Mail
|
||||
|
||||
Interface d'administration centralisée pour la gestion des zones DNS (BIND9) et des comptes mail (PostfixAdmin/MariaDB), avec authentification locale et SSO (Keycloak/OIDC), double authentification TOTP, audit complet et file d'attente des synchronisations vers les agents distants.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Frontend (React/Vite) :3000 │
|
||||
│ LoginPage · DNSManager · MailManager │
|
||||
│ UsersManager · AuditLog · SyncDashboard │
|
||||
└───────────────┬─────────────────────────────┘
|
||||
│ HTTP/JSON
|
||||
┌───────────────▼─────────────────────────────┐
|
||||
│ Backend FastAPI :8000 │
|
||||
│ routers/auth · dns · mail │
|
||||
│ audit · broker · notify │
|
||||
└──────┬──────────────────────┬───────────────┘
|
||||
│ PyMySQL │ Redis (mTLS)
|
||||
┌──────▼──────┐ ┌───────────▼──────────────┐
|
||||
│ MariaDB │ │ Redis │
|
||||
│ netadmin │ │ dns.commands/acks │
|
||||
└─────────────┘ │ mail.commands/acks │
|
||||
│ agent.ready │
|
||||
└──────┬──────────────┬─────┘
|
||||
┌──────────▼──┐ ┌──────▼──────────┐
|
||||
│ dns_agent │ │ mail_agent │
|
||||
│ (serveur │ │ (serveur mail) │
|
||||
│ BIND9) │ │ │
|
||||
└─────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### Composants
|
||||
|
||||
| Composant | Rôle |
|
||||
|-----------|------|
|
||||
| **Frontend** | React 18 + Vite — interface utilisateur |
|
||||
| **Backend** | FastAPI — API REST, authentification, RBAC |
|
||||
| **MariaDB** | Base unique `netadmin` — toutes les tables |
|
||||
| **Redis** | Broker de messages mTLS entre backend et agents |
|
||||
| **dns_agent** | Tourne sur le serveur BIND9 — écrit les fichiers de zone, exécute `rndc` |
|
||||
| **mail_agent** | Tourne sur le serveur mail — gère PostfixAdmin via MariaDB |
|
||||
|
||||
---
|
||||
|
||||
## Prérequis
|
||||
|
||||
### Serveur principal (Backend + Frontend)
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- MariaDB 10.6+
|
||||
- Redis 7+
|
||||
|
||||
### Serveur BIND9 (agent DNS)
|
||||
- Python 3.11+
|
||||
- BIND9 avec `rndc` configuré
|
||||
|
||||
### Serveur Mail (agent mail)
|
||||
- Python 3.11+
|
||||
- PostfixAdmin avec MariaDB
|
||||
- Accès à la base PostfixAdmin
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Base de données MariaDB
|
||||
|
||||
```sql
|
||||
CREATE DATABASE netadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'netadmin'@'localhost' IDENTIFIED BY 'motdepasse';
|
||||
GRANT ALL PRIVILEGES ON netadmin.* TO 'netadmin'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Les tables sont créées automatiquement au premier démarrage du backend.
|
||||
|
||||
### 2. Backend
|
||||
|
||||
```bash
|
||||
cd backend/
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Copier et adapter la configuration :
|
||||
|
||||
```bash
|
||||
cp config.yaml.example config.yaml
|
||||
# Éditer config.yaml (voir section Configuration)
|
||||
```
|
||||
|
||||
Démarrer :
|
||||
|
||||
```bash
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Un compte `admin` / `admin` est créé automatiquement si la base est vide. **Changer le mot de passe immédiatement.**
|
||||
|
||||
### 3. Frontend
|
||||
|
||||
```bash
|
||||
cd frontend/
|
||||
npm install
|
||||
npm run build # Production
|
||||
# ou
|
||||
npm run dev # Développement
|
||||
```
|
||||
|
||||
Le build de production est dans `frontend/dist/` — à servir via nginx ou autre.
|
||||
|
||||
### 4. Certificats mTLS Redis
|
||||
|
||||
```bash
|
||||
cd agents/
|
||||
bash gen_certs.sh
|
||||
```
|
||||
|
||||
Cela génère :
|
||||
- `certs/ca.crt` — Autorité de certification interne
|
||||
- `certs/backend.crt/key` — Certificat du backend
|
||||
- `certs/redis-server.crt/key` — Certificat Redis
|
||||
- `certs/dns-agent.crt/key` — Certificat agent DNS
|
||||
- `certs/mail-agent.crt/key` — Certificat agent mail
|
||||
|
||||
Distribuer les certificats sur les serveurs concernés.
|
||||
|
||||
### 5. Agent DNS (sur le serveur BIND9)
|
||||
|
||||
```bash
|
||||
# Copier dns_agent.py et requirements.txt
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copier le fichier systemd
|
||||
cp netadmin-dns-agent.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now netadmin-dns-agent
|
||||
```
|
||||
|
||||
Adapter le fichier service pour pointer vers les bons chemins et la config Redis.
|
||||
|
||||
### 6. Agent Mail (sur le serveur mail)
|
||||
|
||||
```bash
|
||||
# Copier mail_agent.py et requirements.txt
|
||||
pip install -r requirements.txt
|
||||
|
||||
cp netadmin-mail-agent.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now netadmin-mail-agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
# ── Serveur ───────────────────────────────────────────────────────────────
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
cors_origins:
|
||||
- "http://localhost:3000"
|
||||
- "https://netadmin.example.com"
|
||||
|
||||
# ── Base de données MariaDB ───────────────────────────────────────────────
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
user: "netadmin"
|
||||
password: "motdepasse"
|
||||
name: "netadmin"
|
||||
|
||||
# ── Redis (broker agents) ─────────────────────────────────────────────────
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6380 # Port mTLS
|
||||
db: 0
|
||||
ack_timeout: 15
|
||||
tls:
|
||||
enabled: true
|
||||
client_cert: "/etc/netadmin/certs/backend.crt"
|
||||
client_key: "/etc/netadmin/certs/backend.key"
|
||||
ca_cert: "/etc/netadmin/certs/ca.crt"
|
||||
|
||||
# ── Notifications email ───────────────────────────────────────────────────
|
||||
smtp:
|
||||
enabled: false
|
||||
host: "smtp.example.com"
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from_addr: "netadmin@example.com"
|
||||
from_name: "NetAdmin"
|
||||
use_tls: true # STARTTLS
|
||||
use_ssl: false # SSL direct (port 465)
|
||||
notify_login: true # Email à chaque connexion
|
||||
notify_role_added: true # Email lors de l'ajout d'un rôle
|
||||
|
||||
# ── Authentification ──────────────────────────────────────────────────────
|
||||
auth:
|
||||
session_secret: "" # Généré automatiquement si vide
|
||||
session_ttl_hours: 8
|
||||
oidc:
|
||||
enabled: false
|
||||
issuer: "https://keycloak.example.com/realms/netadmin"
|
||||
client_id: "netadmin"
|
||||
client_secret: ""
|
||||
redirect_uri: "https://netadmin.example.com"
|
||||
use_pkce: true
|
||||
admin_group: "netadmin-admins"
|
||||
|
||||
# ── Synchronisation ───────────────────────────────────────────────────────
|
||||
sync:
|
||||
interval: 300 # Polling automatique toutes les 5 minutes (0 = désactivé)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rôles et permissions
|
||||
|
||||
| Rôle | Périmètre | Accès |
|
||||
|------|-----------|-------|
|
||||
| `global_admin` | Global | Tout |
|
||||
| `dns_admin` | Global | Toutes les zones DNS |
|
||||
| `mail_admin` | Global | Tous les domaines mail |
|
||||
| `domain_admin` | Par domaine | DNS + mail d'un domaine |
|
||||
| `dns_zone_admin` | Par zone | Enregistrements d'une zone |
|
||||
| `mail_domain_admin` | Par domaine | Comptes et alias d'un domaine |
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### DNS
|
||||
- Gestion des zones BIND9 (CRUD)
|
||||
- Enregistrements A, AAAA, CNAME, MX, TXT, NS, PTR, SRV, CAA, TLSA, DS
|
||||
- Activation/désactivation d'enregistrements (commentés dans la zone avec `; [DISABLED]`)
|
||||
- Rechargement de zone via `rndc reload`
|
||||
- Éditeur de zone brute
|
||||
- Indicateur ⏳ sur les enregistrements en attente de synchronisation
|
||||
|
||||
### Mail
|
||||
- Gestion des comptes (quota, activation/désactivation)
|
||||
- Gestion des alias (destinations multiples)
|
||||
- Générateur de mot de passe aléatoire
|
||||
- Synchronisation depuis PostfixAdmin (MariaDB)
|
||||
|
||||
### Authentification & Sécurité
|
||||
- Connexion locale (login/mot de passe)
|
||||
- SSO Keycloak / OpenID Connect avec PKCE
|
||||
- Double authentification TOTP (Google Authenticator, Aegis, Authy…)
|
||||
- TOTP obligatoire configurable par compte (par l'admin)
|
||||
- Gestion des sessions avec expiration
|
||||
|
||||
### Administration
|
||||
- Gestion des utilisateurs et des rôles
|
||||
- Journal d'audit complet (qui, quand, quoi, depuis quelle IP)
|
||||
- File d'attente des synchronisations (avec retry automatique à la reconnexion des agents)
|
||||
- Historique des synchronisations (déclencheur, durée, résultat)
|
||||
- Notifications email (connexion, ajout de rôle)
|
||||
|
||||
### Résilience
|
||||
- Si un agent est indisponible : l'action est mise en file d'attente SQLite
|
||||
- À la reconnexion de l'agent, la file est vidée automatiquement
|
||||
- Badge ⏳ dans l'interface pour les modifications non encore appliquées
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
netadmin/
|
||||
├── backend/
|
||||
│ ├── main.py # Point d'entrée FastAPI
|
||||
│ ├── config.py # Dataclasses de configuration
|
||||
│ ├── config.yaml # Configuration (à adapter)
|
||||
│ ├── db.py # Connexion MariaDB partagée
|
||||
│ ├── audit.py # Journal d'audit + historique sync
|
||||
│ ├── broker.py # Broker Redis + file d'attente
|
||||
│ ├── notify.py # Notifications email (SMTP)
|
||||
│ ├── requirements.txt
|
||||
│ └── routers/
|
||||
│ ├── auth.py # Authentification, RBAC, TOTP
|
||||
│ ├── dns.py # Zones et enregistrements DNS
|
||||
│ └── mail.py # Comptes, alias, domaines mail
|
||||
├── frontend/
|
||||
│ ├── index.html
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ └── src/
|
||||
│ ├── App.jsx
|
||||
│ ├── App.css
|
||||
│ ├── main.jsx
|
||||
│ └── components/
|
||||
│ ├── LoginPage.jsx
|
||||
│ ├── DNSManager.jsx
|
||||
│ ├── MailManager.jsx
|
||||
│ ├── DomainsManager.jsx
|
||||
│ ├── UsersManager.jsx
|
||||
│ ├── UserProfile.jsx
|
||||
│ ├── AuditLog.jsx
|
||||
│ ├── SyncDashboard.jsx
|
||||
│ ├── SyncToast.jsx
|
||||
│ └── MailingLists.jsx
|
||||
└── agents/
|
||||
├── dns_agent.py # Agent BIND9
|
||||
├── mail_agent.py # Agent PostfixAdmin
|
||||
├── gen_certs.sh # Génération certificats mTLS
|
||||
├── requirements.txt
|
||||
├── netadmin-dns-agent.service
|
||||
└── netadmin-mail-agent.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Déploiement nginx (exemple)
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name netadmin.example.com;
|
||||
|
||||
root /opt/netadmin/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Systemd (backend)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=NetAdmin Backend
|
||||
After=network.target mariadb.service redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=netadmin
|
||||
WorkingDirectory=/opt/netadmin/backend
|
||||
ExecStart=/opt/netadmin/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
Usage interne — tous droits réservés.
|
||||
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
dns_agent.py — NetAdmin DNS Agent
|
||||
Runs on the BIND server. Subscribes to Redis dns.commands,
|
||||
writes zone files and calls rndc, then publishes ACK to dns.acks.
|
||||
|
||||
Install on the BIND server:
|
||||
pip install redis pyyaml dnspython
|
||||
python3 dns_agent.py --config /etc/netadmin/config.yaml
|
||||
|
||||
Systemd unit: see docs/dns-agent.service
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import ssl
|
||||
import redis
|
||||
import yaml
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [dns-agent] %(levelname)s %(message)s"
|
||||
)
|
||||
log = logging.getLogger("dns-agent")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Config
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
return cfg.get("dns_agent", {})
|
||||
|
||||
|
||||
|
||||
def build_redis_ssl_context(tls_cfg: dict) -> "ssl.SSLContext | None":
|
||||
"""Build an mTLS SSLContext from the agent tls config dict."""
|
||||
import ssl
|
||||
if not tls_cfg.get("enabled", False):
|
||||
return None
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
ctx.load_verify_locations(cafile=tls_cfg["ca_cert"])
|
||||
ctx.load_cert_chain(
|
||||
certfile=tls_cfg["client_cert"],
|
||||
keyfile=tls_cfg["client_key"]
|
||||
)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
if not tls_cfg.get("check_hostname", True):
|
||||
ctx.check_hostname = False
|
||||
return ctx
|
||||
|
||||
DEFAULT_CFG = {
|
||||
"zones_dir": "/etc/bind/zones",
|
||||
"named_conf_local": "/etc/bind/named.conf.local",
|
||||
"rndc_cmd": "rndc",
|
||||
"redis_host": "localhost",
|
||||
"redis_port": 6379,
|
||||
"redis_password": "",
|
||||
"redis_db": 0,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Zone file generation
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _fmt_ttl(ttl: int | None, zone_ttl: int) -> str:
|
||||
"""Return tab-separated TTL field or empty string if equals zone default."""
|
||||
if ttl is None or ttl == zone_ttl:
|
||||
return ""
|
||||
return f"{ttl}\t"
|
||||
|
||||
|
||||
def _fmt_value(rtype: str, value: str) -> str:
|
||||
"""Normalize record value for zone file."""
|
||||
if rtype == "TXT":
|
||||
# Strip existing outer quotes, re-split into 255-byte chunks
|
||||
raw = value
|
||||
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
|
||||
raw = raw[1:-1]
|
||||
raw = re.sub(r'"\s+"', "", raw)
|
||||
raw = raw.replace('\\"', '"')
|
||||
encoded = raw.encode("utf-8")
|
||||
chunks = []
|
||||
for i in range(0, len(encoded), 255):
|
||||
chunk = encoded[i:i+255].decode("utf-8", errors="replace")
|
||||
chunk = chunk.replace('"', '\\"')
|
||||
chunks.append(f'"{chunk}"')
|
||||
return " ".join(chunks) if chunks else '""'
|
||||
if rtype in ("CNAME", "MX", "NS", "PTR") and not value.endswith("."):
|
||||
return value + "."
|
||||
return value
|
||||
|
||||
|
||||
def build_zone_content(zone: dict) -> str:
|
||||
"""Generate a complete zone file from the zone payload."""
|
||||
name = zone["zone"]
|
||||
admin = zone.get("admin", "hostmaster")
|
||||
ttl = zone.get("ttl", 3600)
|
||||
refresh = zone.get("refresh", 43200)
|
||||
retry = zone.get("retry", 3600)
|
||||
expire = zone.get("expire", 2419200)
|
||||
negative_ttl = zone.get("negative_ttl", 3600)
|
||||
admin_fqdn = admin if admin.endswith(".") else admin + "."
|
||||
|
||||
serial = _next_serial(name)
|
||||
|
||||
lines = [
|
||||
f"; Zone file for {name} — managed by NetAdmin",
|
||||
f"; DO NOT EDIT MANUALLY",
|
||||
f"$ORIGIN {name}.",
|
||||
f"$TTL {ttl}",
|
||||
f"@\tIN\tSOA\tns1.{name}. {admin_fqdn} (",
|
||||
f"\t\t\t{serial:<12}; Serial",
|
||||
f"\t\t\t{refresh:<12}; Refresh",
|
||||
f"\t\t\t{retry:<12}; Retry",
|
||||
f"\t\t\t{expire:<12}; Expire",
|
||||
f"\t\t\t{negative_ttl} )\t; Negative TTL",
|
||||
"",
|
||||
]
|
||||
|
||||
for rec in zone.get("records", []):
|
||||
rtype = rec["type"]
|
||||
rname = rec["name"] or "@"
|
||||
rvalue = _fmt_value(rtype, rec["value"])
|
||||
ttl_f = _fmt_ttl(rec.get("ttl"), ttl)
|
||||
priority = rec.get("priority")
|
||||
active = rec.get("active", True)
|
||||
|
||||
if rtype in ("MX", "SRV") and priority is not None:
|
||||
line = f"{rname}\t{ttl_f}IN\t{rtype}\t{priority}\t{rvalue}"
|
||||
else:
|
||||
line = f"{rname}\t{ttl_f}IN\t{rtype}\t{rvalue}"
|
||||
|
||||
if not active:
|
||||
lines.append(f"; [DISABLED] {line}")
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _next_serial(zone_name: str) -> str:
|
||||
"""Generate YYYYMMDDnn serial, incrementing from existing if same day."""
|
||||
import datetime
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
# Try to read existing serial
|
||||
# (we don't have the path here, handled at write time)
|
||||
return f"{today}01"
|
||||
|
||||
|
||||
def _read_existing_serial(path: Path) -> str | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
for line in path.read_text().splitlines():
|
||||
nc = line.split(";")[0].strip()
|
||||
try:
|
||||
val = int(nc)
|
||||
if len(str(val)) == 10:
|
||||
return str(val)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _compute_serial(path: Path) -> str:
|
||||
import datetime
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
existing = _read_existing_serial(path)
|
||||
if existing and existing.startswith(today):
|
||||
counter = int(existing[8:]) + 1
|
||||
return f"{today}{counter:02d}"
|
||||
return f"{today}01"
|
||||
|
||||
|
||||
def write_zone_file(zone: dict, zones_dir: str) -> Path:
|
||||
"""Write zone file, preserving/incrementing serial."""
|
||||
name = zone["zone"]
|
||||
path = Path(zones_dir) / f"db.{name}"
|
||||
|
||||
# Compute correct serial
|
||||
serial = _compute_serial(path)
|
||||
|
||||
# Build content with correct serial
|
||||
content = build_zone_content(zone)
|
||||
# Replace the placeholder serial
|
||||
content = re.sub(r'(\d{10})\s*;\s*Serial', f"{serial}\t\t\t; Serial", content)
|
||||
|
||||
Path(zones_dir).mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
log.info(f"Zone file written: {path} (serial {serial})")
|
||||
return path
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# named.conf.local management
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def ensure_zone_in_named_conf(zone_name: str, zone_file: Path, named_conf: str):
|
||||
path = Path(named_conf)
|
||||
if not path.exists():
|
||||
path.write_text("")
|
||||
content = path.read_text()
|
||||
if f'zone "{zone_name}"' in content:
|
||||
return # already present
|
||||
entry = f'\nzone "{zone_name}" {{\n type master;\n file "{zone_file}";\n}};\n'
|
||||
path.write_text(content + entry)
|
||||
log.info(f"Added zone '{zone_name}' to {named_conf}")
|
||||
|
||||
|
||||
def remove_zone_from_named_conf(zone_name: str, named_conf: str):
|
||||
path = Path(named_conf)
|
||||
if not path.exists():
|
||||
return
|
||||
content = path.read_text()
|
||||
pattern = rf'\nzone\s+"{re.escape(zone_name)}"\s*\{{[^}}]+\}};\n'
|
||||
new = re.sub(pattern, "", content, flags=re.DOTALL)
|
||||
if new != content:
|
||||
path.write_text(new)
|
||||
log.info(f"Removed zone '{zone_name}' from {named_conf}")
|
||||
|
||||
|
||||
def rndc(cmd: str, zone: str, rndc_cmd: str):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[rndc_cmd, cmd, zone],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.warning(f"rndc {cmd} {zone}: {result.stderr.strip()}")
|
||||
else:
|
||||
log.info(f"rndc {cmd} {zone}: OK")
|
||||
except FileNotFoundError:
|
||||
log.warning(f"rndc not found at '{rndc_cmd}' — zone changes written but not reloaded")
|
||||
except Exception as e:
|
||||
log.warning(f"rndc error: {e}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Command handlers
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def handle_apply_zone(payload: dict, cfg: dict) -> dict:
|
||||
try:
|
||||
zone_name = payload["zone"]
|
||||
path = write_zone_file(payload, cfg["zones_dir"])
|
||||
ensure_zone_in_named_conf(zone_name, path, cfg["named_conf_local"])
|
||||
rndc("reload", zone_name, cfg["rndc_cmd"])
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
log.error(f"apply_zone error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def handle_delete_zone(payload: dict, cfg: dict) -> dict:
|
||||
try:
|
||||
zone_name = payload["zone"]
|
||||
remove_zone_from_named_conf(zone_name, cfg["named_conf_local"])
|
||||
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
try:
|
||||
subprocess.run([cfg["rndc_cmd"], "reconfig"], capture_output=True, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
log.info(f"Zone '{zone_name}' deleted")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
log.error(f"delete_zone error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def handle_get_zone_raw(payload: dict, cfg: dict) -> dict:
|
||||
try:
|
||||
zone_name = payload["zone"]
|
||||
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
|
||||
if not path.exists():
|
||||
return {"success": False, "error": f"Zone file not found: {path}"}
|
||||
return {"success": True, "content": path.read_text(), "path": str(path)}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def handle_save_zone_raw(payload: dict, cfg: dict) -> dict:
|
||||
try:
|
||||
zone_name = payload["zone"]
|
||||
content = payload["content"]
|
||||
# Validate with dnspython before writing
|
||||
import dns.zone as dz
|
||||
dz.from_text(content, origin=zone_name, check_origin=False)
|
||||
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
|
||||
backup = path.with_suffix(".bak")
|
||||
if path.exists():
|
||||
backup.write_text(path.read_text())
|
||||
path.write_text(content)
|
||||
rndc("reload", zone_name, cfg["rndc_cmd"])
|
||||
return {"success": True, "content": path.read_text()}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
|
||||
def handle_get_state(payload: dict, cfg: dict) -> dict:
|
||||
"""Read all zones from named.conf.local + their zone files via dnspython.
|
||||
|
||||
Returns the full authoritative state so the backend can sync its SQLite.
|
||||
Payload:
|
||||
{} → all zones
|
||||
{"zone": "example.com"} → one zone only
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"zones": [
|
||||
{
|
||||
"name": "example.com",
|
||||
"admin": "hostmaster.example.com",
|
||||
"ttl": 3600,
|
||||
"refresh": 43200,
|
||||
"retry": 3600,
|
||||
"expire": 2419200,
|
||||
"negative_ttl": 3600,
|
||||
"records": [
|
||||
{"name":"www","type":"A","ttl":null,"value":"1.2.3.4","priority":null},
|
||||
...
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
import dns.zone as dz
|
||||
import dns.rdatatype
|
||||
|
||||
filter_zone = payload.get("zone")
|
||||
zones_dir = cfg["zones_dir"]
|
||||
named_conf = cfg["named_conf_local"]
|
||||
|
||||
# Read zone names from named.conf.local
|
||||
all_zone_names = []
|
||||
try:
|
||||
nc_text = Path(named_conf).read_text()
|
||||
all_zone_names = re.findall(r'zone\s+"([^"]+)"', nc_text)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Cannot read {named_conf}: {e}"}
|
||||
|
||||
if filter_zone:
|
||||
all_zone_names = [z for z in all_zone_names if z == filter_zone]
|
||||
|
||||
result = []
|
||||
for zone_name in all_zone_names:
|
||||
path = Path(zones_dir) / f"db.{zone_name}"
|
||||
if not path.exists():
|
||||
log.warning(f"Zone file missing for {zone_name}: {path}")
|
||||
continue
|
||||
|
||||
try:
|
||||
z = dz.from_file(str(path), origin=zone_name, check_origin=False)
|
||||
except Exception as e:
|
||||
log.warning(f"Cannot parse {path}: {e}")
|
||||
continue
|
||||
|
||||
# Read $TTL and SOA fields
|
||||
ttl = 3600
|
||||
admin = "hostmaster"
|
||||
refresh = 43200
|
||||
retry = 3600
|
||||
expire = 2419200
|
||||
negative_ttl = 3600
|
||||
|
||||
raw_lines = path.read_text().splitlines()
|
||||
in_soa = False
|
||||
soa_nums = []
|
||||
for line in raw_lines:
|
||||
stripped = line.strip().split(";")[0].strip()
|
||||
upper = stripped.upper()
|
||||
if re.match(r'^\$TTL\s+', stripped, re.IGNORECASE):
|
||||
try:
|
||||
ttl = int(stripped.split()[1])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
if "SOA" in upper and not in_soa:
|
||||
in_soa = True
|
||||
parts = stripped.split()
|
||||
idx = next((i for i,p in enumerate(parts) if p.upper()=="SOA"), None)
|
||||
if idx is not None and idx + 2 < len(parts):
|
||||
admin = parts[idx+2].rstrip(".")
|
||||
for token in (parts[idx+3:] if idx else []):
|
||||
if token in ("(",")"): continue
|
||||
try: soa_nums.append(int(token))
|
||||
except ValueError: pass
|
||||
elif in_soa:
|
||||
for token in stripped.split():
|
||||
if token == ")": in_soa = False; break
|
||||
try: soa_nums.append(int(token))
|
||||
except ValueError: pass
|
||||
if not in_soa and soa_nums:
|
||||
break
|
||||
|
||||
if len(soa_nums) >= 5:
|
||||
refresh = soa_nums[1]
|
||||
retry = soa_nums[2]
|
||||
expire = soa_nums[3]
|
||||
negative_ttl = soa_nums[4]
|
||||
|
||||
# Parse records (skip SOA)
|
||||
records = []
|
||||
for name_obj, node in z.nodes.items():
|
||||
name_str = str(name_obj)
|
||||
if name_str == "@":
|
||||
name_str = ""
|
||||
for rdataset in node.rdatasets:
|
||||
rdtype = dns.rdatatype.to_text(rdataset.rdtype)
|
||||
if rdtype == "SOA":
|
||||
continue
|
||||
rec_ttl = rdataset.ttl if rdataset.ttl != ttl else None
|
||||
for rdata in rdataset:
|
||||
value = rdata.to_text()
|
||||
priority = None
|
||||
if rdtype == "MX":
|
||||
parts = value.split(None, 1)
|
||||
priority = int(parts[0])
|
||||
value = parts[1] if len(parts) > 1 else ""
|
||||
elif rdtype == "SRV":
|
||||
parts = value.split(None, 3)
|
||||
priority = int(parts[0]) if parts else None
|
||||
records.append({
|
||||
"name": name_str,
|
||||
"type": rdtype,
|
||||
"ttl": rec_ttl,
|
||||
"value": value,
|
||||
"priority": priority,
|
||||
})
|
||||
|
||||
result.append({
|
||||
"name": zone_name,
|
||||
"admin": admin,
|
||||
"ttl": ttl,
|
||||
"refresh": refresh,
|
||||
"retry": retry,
|
||||
"expire": expire,
|
||||
"negative_ttl": negative_ttl,
|
||||
"records": records,
|
||||
})
|
||||
log.info(f"get_state: parsed {zone_name} ({len(records)} records)")
|
||||
|
||||
return {"success": True, "zones": result}
|
||||
|
||||
def handle_reload_zone(payload: dict, cfg: dict) -> dict:
|
||||
"""Force rndc reload on a zone without rewriting the file."""
|
||||
try:
|
||||
zone_name = payload["zone"]
|
||||
rndc("reload", zone_name, cfg["rndc_cmd"])
|
||||
log.info(f"Zone '{zone_name}' reloaded via rndc")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
log.error(f"reload_zone error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"apply_zone": handle_apply_zone,
|
||||
"reload_zone": handle_reload_zone,
|
||||
"delete_zone": handle_delete_zone,
|
||||
"get_zone_raw": handle_get_zone_raw,
|
||||
"get_state": handle_get_state,
|
||||
"save_zone_raw": handle_save_zone_raw,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Main loop
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def run(cfg: dict):
|
||||
redis_kwargs = {
|
||||
"host": cfg["redis_host"],
|
||||
"port": cfg["redis_port"],
|
||||
"db": cfg["redis_db"],
|
||||
"decode_responses": True,
|
||||
}
|
||||
if cfg.get("redis_password"):
|
||||
redis_kwargs["password"] = cfg["redis_password"]
|
||||
tls_cfg = cfg.get("tls", {})
|
||||
if tls_cfg.get("enabled", False):
|
||||
redis_kwargs["ssl"] = True
|
||||
redis_kwargs["ssl_certfile"] = tls_cfg["client_cert"]
|
||||
redis_kwargs["ssl_keyfile"] = tls_cfg["client_key"]
|
||||
redis_kwargs["ssl_ca_certs"] = tls_cfg["ca_cert"]
|
||||
redis_kwargs["ssl_cert_reqs"] = "required"
|
||||
log.info("Redis mTLS enabled")
|
||||
else:
|
||||
log.warning("Redis TLS disabled — traffic is unencrypted")
|
||||
|
||||
r = redis.Redis(**redis_kwargs)
|
||||
r.ping()
|
||||
log.info(f"Connected to Redis at {cfg['redis_host']}:{cfg['redis_port']}")
|
||||
|
||||
pubsub = r.pubsub()
|
||||
pubsub.subscribe("dns.commands")
|
||||
log.info("Subscribed to dns.commands — waiting for messages…")
|
||||
# Announce reconnection so the backend can flush the pending queue
|
||||
import socket as _socket
|
||||
r.publish("agent.ready", json.dumps({"service": "dns", "host": _socket.gethostname()}))
|
||||
log.info(f"Published agent.ready for service 'dns'")
|
||||
|
||||
for message in pubsub.listen():
|
||||
if message["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(message["data"])
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
log.warning("Malformed message received, ignored")
|
||||
continue
|
||||
|
||||
# mTLS: authentication is handled by Redis TLS layer (tls-auth-clients yes).
|
||||
# No token check needed — only clients with a valid CA-signed cert can connect.
|
||||
|
||||
|
||||
msg_id = msg.get("id", "unknown")
|
||||
action = msg.get("action", "")
|
||||
payload = msg.get("payload", {})
|
||||
|
||||
log.info(f"Received action '{action}' (id={msg_id})")
|
||||
|
||||
handler = HANDLERS.get(action)
|
||||
if not handler:
|
||||
ack = {"id": msg_id, "success": False, "error": f"Unknown action '{action}'"}
|
||||
else:
|
||||
ack = handler(payload, cfg)
|
||||
ack["id"] = msg_id
|
||||
|
||||
r.publish("dns.acks", json.dumps(ack))
|
||||
log.info(f"ACK sent for '{action}' (id={msg_id}): success={ack.get('success')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="NetAdmin DNS Agent")
|
||||
parser.add_argument("--config", default="/etc/netadmin/config.yaml",
|
||||
help="Path to config.yaml")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg_path = args.config
|
||||
if not Path(cfg_path).exists():
|
||||
log.error(f"Config file not found: {cfg_path}")
|
||||
sys.exit(1)
|
||||
|
||||
agent_cfg = {**DEFAULT_CFG, **load_config(cfg_path)}
|
||||
log.info(f"DNS agent starting (zones_dir={agent_cfg['zones_dir']})")
|
||||
|
||||
try:
|
||||
run(agent_cfg)
|
||||
except KeyboardInterrupt:
|
||||
log.info("DNS agent stopped")
|
||||
except Exception as e:
|
||||
log.error(f"Fatal error: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
# gen_certs.sh — Generate mTLS certificates for NetAdmin
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x gen_certs.sh
|
||||
# ./gen_certs.sh [output_dir]
|
||||
#
|
||||
# Output:
|
||||
# certs/
|
||||
# ca.crt — CA certificate (distribute to ALL nodes)
|
||||
# ca.key — CA private key (keep on CA machine only)
|
||||
# backend.crt — Backend client cert (for broker.py)
|
||||
# backend.key — Backend private key
|
||||
# dns-agent.crt — DNS agent cert
|
||||
# dns-agent.key — DNS agent private key
|
||||
# mail-agent.crt — Mail agent cert
|
||||
# mail-agent.key — Mail agent private key
|
||||
# redis-server.crt — Redis server cert
|
||||
# redis-server.key — Redis server private key
|
||||
#
|
||||
# After generation:
|
||||
# - Install ca.crt + redis-server.{crt,key} on the Redis/NetAdmin server
|
||||
# - Install ca.crt + backend.{crt,key} on the NetAdmin backend server
|
||||
# - Install ca.crt + dns-agent.{crt,key} on the BIND server
|
||||
# - Install ca.crt + mail-agent.{crt,key} on the mail server
|
||||
# - Configure Redis with tls-cert-file, tls-key-file, tls-ca-cert-file
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT="${1:-certs}"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
DAYS=3650 # 10 years for CA
|
||||
CERT_DAYS=825 # ~2 years for node certs (Apple/browser limit, doesn't apply here but good practice)
|
||||
KEYSIZE=4096
|
||||
|
||||
echo "=== NetAdmin mTLS Certificate Generator ==="
|
||||
echo "Output directory: $OUT"
|
||||
echo ""
|
||||
|
||||
# ── CA ─────────────────────────────────────────────────────────────────────
|
||||
echo "[1/5] Generating CA..."
|
||||
openssl genrsa -out "$OUT/ca.key" $KEYSIZE 2>/dev/null
|
||||
|
||||
openssl req -new -x509 \
|
||||
-key "$OUT/ca.key" \
|
||||
-out "$OUT/ca.crt" \
|
||||
-days $DAYS \
|
||||
-subj "/C=FR/O=NetAdmin/CN=NetAdmin Internal CA" \
|
||||
-extensions v3_ca \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
|
||||
echo " CA created: $OUT/ca.crt"
|
||||
|
||||
# ── Helper function ─────────────────────────────────────────────────────────
|
||||
gen_cert() {
|
||||
local NAME="$1"
|
||||
local CN="$2"
|
||||
local USAGE="$3" # "server" or "client" or "both"
|
||||
local SANS="${4:-}"
|
||||
|
||||
echo "[?] Generating $NAME certificate (CN=$CN)..."
|
||||
|
||||
openssl genrsa -out "$OUT/$NAME.key" $KEYSIZE 2>/dev/null
|
||||
|
||||
# Build SAN extension
|
||||
local SAN_EXT=""
|
||||
if [ -n "$SANS" ]; then
|
||||
SAN_EXT="subjectAltName=$SANS"
|
||||
fi
|
||||
|
||||
# Build EKU
|
||||
local EKU=""
|
||||
case "$USAGE" in
|
||||
server) EKU="extendedKeyUsage=serverAuth" ;;
|
||||
client) EKU="extendedKeyUsage=clientAuth" ;;
|
||||
both) EKU="extendedKeyUsage=serverAuth,clientAuth" ;;
|
||||
esac
|
||||
|
||||
# Create CSR
|
||||
openssl req -new \
|
||||
-key "$OUT/$NAME.key" \
|
||||
-out "$OUT/$NAME.csr" \
|
||||
-subj "/C=FR/O=NetAdmin/CN=$CN" 2>/dev/null
|
||||
|
||||
# Sign with CA
|
||||
local EXTFILE
|
||||
EXTFILE=$(mktemp)
|
||||
echo "[ ext ]" >> "$EXTFILE"
|
||||
echo "basicConstraints=CA:FALSE" >> "$EXTFILE"
|
||||
echo "keyUsage=critical,digitalSignature,keyEncipherment" >> "$EXTFILE"
|
||||
[ -n "$EKU" ] && echo "$EKU" >> "$EXTFILE"
|
||||
[ -n "$SAN_EXT" ] && echo "$SAN_EXT" >> "$EXTFILE"
|
||||
|
||||
openssl x509 -req \
|
||||
-in "$OUT/$NAME.csr" \
|
||||
-CA "$OUT/ca.crt" \
|
||||
-CAkey "$OUT/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$OUT/$NAME.crt" \
|
||||
-days $CERT_DAYS \
|
||||
-extfile "$EXTFILE" \
|
||||
-extensions ext \
|
||||
2>/dev/null
|
||||
|
||||
rm -f "$OUT/$NAME.csr" "$EXTFILE"
|
||||
echo " Created: $OUT/$NAME.crt"
|
||||
}
|
||||
|
||||
# ── Redis server cert ───────────────────────────────────────────────────────
|
||||
# Update the IP/hostname below to match your Redis server
|
||||
REDIS_HOST="${REDIS_HOST:-localhost}"
|
||||
echo ""
|
||||
echo "[2/5] Redis server cert (hostname: $REDIS_HOST)"
|
||||
echo " Set REDIS_HOST env var to override (e.g. REDIS_HOST=redis.example.com ./gen_certs.sh)"
|
||||
gen_cert "redis-server" "redis-server" "server" "DNS:$REDIS_HOST,DNS:localhost,IP:127.0.0.1"
|
||||
|
||||
# ── Backend client cert ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[3/5] Backend client cert"
|
||||
gen_cert "backend" "netadmin-backend" "client"
|
||||
|
||||
# ── DNS agent cert ──────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[4/5] DNS agent cert"
|
||||
gen_cert "dns-agent" "netadmin-dns-agent" "client"
|
||||
|
||||
# ── Mail agent cert ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[5/5] Mail agent cert"
|
||||
gen_cert "mail-agent" "netadmin-mail-agent" "client"
|
||||
|
||||
# ── Set permissions ─────────────────────────────────────────────────────────
|
||||
chmod 644 "$OUT"/*.crt
|
||||
chmod 600 "$OUT"/*.key
|
||||
echo ""
|
||||
echo "=== Done! ==="
|
||||
echo ""
|
||||
echo "Files generated in $OUT/:"
|
||||
ls -lh "$OUT/"
|
||||
echo ""
|
||||
echo "=== Deployment checklist ==="
|
||||
echo ""
|
||||
echo "On the NetAdmin/Redis server:"
|
||||
echo " mkdir -p /etc/netadmin/certs"
|
||||
echo " cp $OUT/ca.crt $OUT/redis-server.{crt,key} $OUT/backend.{crt,key} /etc/netadmin/certs/"
|
||||
echo " chown redis:redis /etc/netadmin/certs/redis-server.key"
|
||||
echo ""
|
||||
echo " Add to /etc/redis/redis.conf:"
|
||||
echo " tls-port 6380"
|
||||
echo " port 0 # disable plain"
|
||||
echo " tls-cert-file /etc/netadmin/certs/redis-server.crt"
|
||||
echo " tls-key-file /etc/netadmin/certs/redis-server.key"
|
||||
echo " tls-ca-cert-file /etc/netadmin/certs/ca.crt"
|
||||
echo " tls-auth-clients yes # require client cert (mTLS)"
|
||||
echo " tls-protocols 'TLSv1.2 TLSv1.3'"
|
||||
echo ""
|
||||
echo "On the BIND server:"
|
||||
echo " mkdir -p /etc/netadmin/certs"
|
||||
echo " cp $OUT/ca.crt $OUT/dns-agent.{crt,key} /etc/netadmin/certs/"
|
||||
echo ""
|
||||
echo "On the mail server:"
|
||||
echo " mkdir -p /etc/netadmin/certs"
|
||||
echo " cp $OUT/ca.crt $OUT/mail-agent.{crt,key} /etc/netadmin/certs/"
|
||||
echo ""
|
||||
echo "Then update /etc/netadmin/config.yaml on each server (see tls section)."
|
||||
@@ -0,0 +1,626 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mail_agent.py — NetAdmin Mail Agent (PostfixAdmin MySQL schema)
|
||||
Runs on the Postfix/Dovecot server.
|
||||
Subscribes to Redis mail.commands, writes to the existing PostfixAdmin
|
||||
MariaDB database, then publishes ACK.
|
||||
|
||||
Schema targeted:
|
||||
domain (domain, description, aliases, mailboxes, maxquota, quota,
|
||||
transport, backupmx, created, modified, active)
|
||||
mailbox (username, password, name, maildir, quota, local_part,
|
||||
domain, created, modified, active)
|
||||
alias (address, goto, domain, created, modified, active)
|
||||
|
||||
Install on the mail server:
|
||||
pip install redis pyyaml PyMySQL
|
||||
python3 mail_agent.py --config /etc/netadmin/config.yaml
|
||||
|
||||
Postfix/Dovecot already query MySQL directly — no file reload needed
|
||||
for account changes. We call 'postfix reload' only when domain config
|
||||
changes (transport, etc.).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import sys
|
||||
import subprocess
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import redis
|
||||
import yaml
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
except ImportError:
|
||||
print("ERROR: PyMySQL not installed. Run: pip install PyMySQL")
|
||||
sys.exit(1)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [mail-agent] %(levelname)s %(message)s"
|
||||
)
|
||||
log = logging.getLogger("mail-agent")
|
||||
|
||||
NOW_DEFAULT = datetime.datetime(2000, 1, 1)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Config
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
return cfg.get("mail_agent", {})
|
||||
|
||||
|
||||
DEFAULT_CFG = {
|
||||
# MariaDB connection
|
||||
"db_host": "localhost",
|
||||
"db_port": 3306,
|
||||
"db_name": "postfix",
|
||||
"db_user": "postfix",
|
||||
"db_password": "",
|
||||
# Postfix reload (only needed for domain-level changes)
|
||||
"postfix_reload_cmd": "postfix reload",
|
||||
# Redis
|
||||
"redis_host": "localhost",
|
||||
"redis_port": 6380,
|
||||
"redis_password": "",
|
||||
"redis_db": 0,
|
||||
"tls": {
|
||||
"enabled": False,
|
||||
"ca_cert": "/etc/netadmin/certs/ca.crt",
|
||||
"client_cert": "/etc/netadmin/certs/mail-agent.crt",
|
||||
"client_key": "/etc/netadmin/certs/mail-agent.key",
|
||||
"check_hostname": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# mTLS helper
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def build_redis_ssl_context(tls_cfg: dict):
|
||||
if not tls_cfg.get("enabled", False):
|
||||
return None
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
ctx.load_verify_locations(cafile=tls_cfg["ca_cert"])
|
||||
ctx.load_cert_chain(
|
||||
certfile=tls_cfg["client_cert"],
|
||||
keyfile=tls_cfg["client_key"]
|
||||
)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
if not tls_cfg.get("check_hostname", True):
|
||||
ctx.check_hostname = False
|
||||
return ctx
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DB helper
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_db(cfg: dict):
|
||||
return pymysql.connect(
|
||||
host=cfg["db_host"],
|
||||
port=int(cfg["db_port"]),
|
||||
user=cfg["db_user"],
|
||||
password=cfg["db_password"],
|
||||
database=cfg["db_name"],
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Command handlers
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def handle_apply_domain(payload: dict, cfg: dict) -> dict:
|
||||
"""
|
||||
Sync domain row + all mailboxes + aliases for one domain.
|
||||
|
||||
Payload (from mail.py _apply_domain):
|
||||
{
|
||||
"domain": "example.com",
|
||||
"config": {
|
||||
"description": "",
|
||||
"max_accounts": 0, # domain.mailboxes (0=unlimited)
|
||||
"max_quota_mb": 0, # domain.quota (MB, 0=unlimited)
|
||||
"max_mailbox_quota_mb": 1024, # domain.maxquota (MB per mailbox)
|
||||
"active": true
|
||||
},
|
||||
"accounts": [
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"local_part": "user",
|
||||
"domain": "example.com",
|
||||
"password_hash": "{SHA512}...",
|
||||
"name": "",
|
||||
"quota": 1024, # MB
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
{"address": "alias@example.com", "goto": "dest@example.com"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
domain_name = payload["domain"]
|
||||
dcfg = payload.get("config", {})
|
||||
accounts = payload.get("accounts", [])
|
||||
aliases = payload.get("aliases", [])
|
||||
|
||||
try:
|
||||
conn = get_db(cfg)
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
|
||||
# ── domain row ─────────────────────────────────────
|
||||
active = int(dcfg.get("active", True))
|
||||
mailboxes = int(dcfg.get("max_accounts", 0))
|
||||
quota_mb = int(dcfg.get("max_quota_mb", 0))
|
||||
maxquota_mb = int(dcfg.get("max_mailbox_quota_mb", 0))
|
||||
description = dcfg.get("description", "")
|
||||
transport = dcfg.get("transport", "virtual")
|
||||
|
||||
cur.execute("SELECT domain FROM domain WHERE domain=%s", (domain_name,))
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"""UPDATE domain SET
|
||||
description=%s, mailboxes=%s, maxquota=%s, quota=%s,
|
||||
transport=%s, active=%s, modified=%s
|
||||
WHERE domain=%s""",
|
||||
(description, mailboxes, maxquota_mb, quota_mb,
|
||||
transport, active, now(), domain_name)
|
||||
)
|
||||
log.info(f"Updated domain: {domain_name}")
|
||||
else:
|
||||
cur.execute(
|
||||
"""INSERT INTO domain
|
||||
(domain, description, aliases, mailboxes, maxquota, quota,
|
||||
transport, backupmx, created, modified, active)
|
||||
VALUES (%s,%s,0,%s,%s,%s,%s,0,%s,%s,%s)""",
|
||||
(domain_name, description, mailboxes, maxquota_mb, quota_mb,
|
||||
transport, now(), now(), active)
|
||||
)
|
||||
log.info(f"Inserted domain: {domain_name}")
|
||||
|
||||
# ── mailboxes ──────────────────────────────────────
|
||||
# Get current usernames for this domain
|
||||
cur.execute(
|
||||
"SELECT username FROM mailbox WHERE domain=%s", (domain_name,)
|
||||
)
|
||||
existing_users = {r["username"] for r in cur.fetchall()}
|
||||
incoming_users = {a["username"] for a in accounts}
|
||||
|
||||
# Delete removed accounts
|
||||
for username in existing_users - incoming_users:
|
||||
cur.execute("DELETE FROM mailbox WHERE username=%s", (username,))
|
||||
# Also remove the auto-alias created by PostfixAdmin
|
||||
cur.execute(
|
||||
"DELETE FROM alias WHERE address=%s AND domain=%s",
|
||||
(username, domain_name)
|
||||
)
|
||||
log.info(f"Deleted mailbox: {username}")
|
||||
|
||||
# Upsert active accounts
|
||||
for acc in accounts:
|
||||
username = acc["username"]
|
||||
local_part = acc.get("local_part") or username.split("@")[0]
|
||||
password = acc.get("password_hash", "")
|
||||
name = acc.get("name", "")
|
||||
# quota: our backend stores MB, PostfixAdmin stores bytes
|
||||
quota_bytes = int(acc.get("quota", 1024)) * 1024 * 1024
|
||||
acc_active = int(acc.get("active", True))
|
||||
maildir = f"{domain_name}/{local_part}/"
|
||||
|
||||
if username in existing_users:
|
||||
update_fields = {
|
||||
"name": name,
|
||||
"quota": quota_bytes,
|
||||
"active": acc_active,
|
||||
"modified": now(),
|
||||
}
|
||||
# Only update password if provided and non-empty
|
||||
if password:
|
||||
update_fields["password"] = password
|
||||
set_clause = ", ".join(f"{k}=%s" for k in update_fields)
|
||||
cur.execute(
|
||||
f"UPDATE mailbox SET {set_clause} WHERE username=%s",
|
||||
(*update_fields.values(), username)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""INSERT INTO mailbox
|
||||
(username, password, name, maildir, quota, local_part,
|
||||
domain, created, modified, active)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(username, password, name, maildir, quota_bytes,
|
||||
local_part, domain_name, now(), now(), acc_active)
|
||||
)
|
||||
# PostfixAdmin convention: create a self-alias for each mailbox
|
||||
cur.execute("SELECT address FROM alias WHERE address=%s", (username,))
|
||||
if not cur.fetchone():
|
||||
cur.execute(
|
||||
"""INSERT INTO alias
|
||||
(address, goto, domain, created, modified, active)
|
||||
VALUES (%s,%s,%s,%s,%s,1)""",
|
||||
(username, username, domain_name, now(), now())
|
||||
)
|
||||
log.info(f"Created mailbox: {username}")
|
||||
|
||||
# ── aliases (non-mailbox) ──────────────────────────
|
||||
# Get current non-mailbox aliases for this domain
|
||||
cur.execute(
|
||||
"SELECT address FROM alias WHERE domain=%s", (domain_name,)
|
||||
)
|
||||
existing_aliases = {r["address"] for r in cur.fetchall()}
|
||||
# Mailbox self-aliases are managed above — don't touch them here
|
||||
incoming_alias_addresses = {a["address"] for a in aliases}
|
||||
|
||||
# Remove aliases that no longer exist
|
||||
# (only remove if not a mailbox self-alias)
|
||||
for addr in existing_aliases - incoming_alias_addresses - incoming_users:
|
||||
cur.execute(
|
||||
"DELETE FROM alias WHERE address=%s AND domain=%s",
|
||||
(addr, domain_name)
|
||||
)
|
||||
log.info(f"Deleted alias: {addr}")
|
||||
|
||||
# Upsert aliases
|
||||
for alias in aliases:
|
||||
address = alias["address"]
|
||||
goto = alias["goto"] # comma-separated if multiple destinations
|
||||
if address in existing_aliases:
|
||||
cur.execute(
|
||||
"UPDATE alias SET goto=%s, modified=%s WHERE address=%s",
|
||||
(goto, now(), address)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""INSERT INTO alias
|
||||
(address, goto, domain, created, modified, active)
|
||||
VALUES (%s,%s,%s,%s,%s,1)""",
|
||||
(address, goto, domain_name, now(), now())
|
||||
)
|
||||
log.info(f"Created alias: {address} → {goto}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Postfix reads MySQL directly — reload only needed for domain-level changes
|
||||
_reload_postfix(cfg)
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"apply_domain error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def handle_delete_domain(payload: dict, cfg: dict) -> dict:
|
||||
"""Remove all mailboxes and aliases for a domain, then the domain itself."""
|
||||
domain_name = payload["domain"]
|
||||
try:
|
||||
conn = get_db(cfg)
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM mailbox WHERE domain=%s", (domain_name,))
|
||||
cur.execute("DELETE FROM alias WHERE domain=%s", (domain_name,))
|
||||
cur.execute("DELETE FROM domain WHERE domain=%s", (domain_name,))
|
||||
conn.commit()
|
||||
log.info(f"Domain '{domain_name}' deleted from MariaDB")
|
||||
_reload_postfix(cfg)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
log.error(f"delete_domain error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def handle_get_quota_usage(payload: dict, cfg: dict) -> dict:
|
||||
"""
|
||||
Return current quota usage per mailbox for a domain.
|
||||
Reads from quota2 table (Dovecot quota backend) if available.
|
||||
"""
|
||||
domain_name = payload["domain"]
|
||||
try:
|
||||
conn = get_db(cfg)
|
||||
usage = {}
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
# quota2 is populated by Dovecot dict quota backend
|
||||
cur.execute(
|
||||
"SHOW TABLES LIKE 'quota2'"
|
||||
)
|
||||
has_quota2 = cur.fetchone() is not None
|
||||
|
||||
if has_quota2:
|
||||
cur.execute(
|
||||
"""SELECT username,
|
||||
bytes AS used_bytes,
|
||||
ROUND(bytes/1048576) AS used_mb
|
||||
FROM quota2
|
||||
WHERE username LIKE %s""",
|
||||
(f"%@{domain_name}",)
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
usage[row["username"]] = {
|
||||
"used_bytes": row["used_bytes"],
|
||||
"used_mb": int(row["used_mb"] or 0),
|
||||
}
|
||||
else:
|
||||
# Fallback: quota table (older Dovecot setup)
|
||||
cur.execute(
|
||||
"""SELECT username,
|
||||
current_bytes AS used_bytes,
|
||||
ROUND(current_bytes/1048576) AS used_mb
|
||||
FROM quota
|
||||
WHERE username LIKE %s""",
|
||||
(f"%@{domain_name}",)
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
usage[row["username"]] = {
|
||||
"used_bytes": row["used_bytes"],
|
||||
"used_mb": int(row["used_mb"] or 0),
|
||||
}
|
||||
|
||||
return {"success": True, "domain": domain_name, "usage": usage}
|
||||
except Exception as e:
|
||||
log.error(f"get_quota_usage error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _reload_postfix(cfg: dict):
|
||||
cmd = cfg.get("postfix_reload_cmd", "postfix reload")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd.split(), capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.warning(f"postfix reload: {result.stderr.strip()}")
|
||||
else:
|
||||
log.info("Postfix reloaded")
|
||||
except FileNotFoundError:
|
||||
log.warning(f"postfix command not found: '{cmd}'")
|
||||
except Exception as e:
|
||||
log.warning(f"postfix reload error: {e}")
|
||||
|
||||
|
||||
|
||||
def handle_get_state(payload: dict, cfg: dict) -> dict:
|
||||
"""Return the full state of MariaDB for all domains (or a specific one).
|
||||
|
||||
This is the canonical source of truth — used by the backend to sync
|
||||
its local SQLite cache at startup and on demand.
|
||||
|
||||
Payload:
|
||||
{} → all domains
|
||||
{"domain": "example.com"} → one domain only
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"domains": [
|
||||
{
|
||||
"name": "example.com",
|
||||
"active": true,
|
||||
"max_accounts": 0,
|
||||
"max_quota_mb": 0,
|
||||
"accounts": [
|
||||
{
|
||||
"username": "user", # local part
|
||||
"email": "user@example.com",
|
||||
"quota_mb": 1024, # converted from bytes
|
||||
"active": true
|
||||
# password_hash intentionally NOT returned for security
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
{"address": "alias@example.com", "goto": "dest@example.com", "active": true}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
filter_domain = payload.get("domain")
|
||||
try:
|
||||
conn = get_db(cfg)
|
||||
result = []
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
# Fetch domains
|
||||
if filter_domain:
|
||||
cur.execute("SELECT * FROM domain WHERE domain=%s", (filter_domain,))
|
||||
else:
|
||||
cur.execute("SELECT * FROM domain WHERE active=1 ORDER BY domain")
|
||||
domains = cur.fetchall()
|
||||
|
||||
for dom in domains:
|
||||
domain_name = dom["domain"]
|
||||
|
||||
# Mailboxes — exclude password for security
|
||||
cur.execute(
|
||||
"""SELECT username, local_part, name,
|
||||
ROUND(quota / 1048576) AS quota_mb,
|
||||
active
|
||||
FROM mailbox
|
||||
WHERE domain=%s
|
||||
ORDER BY local_part""",
|
||||
(domain_name,)
|
||||
)
|
||||
mailboxes = cur.fetchall()
|
||||
|
||||
# Aliases — exclude self-aliases (address == goto, used for mailboxes)
|
||||
cur.execute(
|
||||
"""SELECT address, goto, active
|
||||
FROM alias
|
||||
WHERE domain=%s
|
||||
AND address != goto
|
||||
ORDER BY address""",
|
||||
(domain_name,)
|
||||
)
|
||||
aliases = cur.fetchall()
|
||||
|
||||
# Quota usage from quota2 (if available)
|
||||
quota_usage = {}
|
||||
cur.execute("SHOW TABLES LIKE 'quota2'")
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"""SELECT username,
|
||||
ROUND(bytes/1048576) AS used_mb
|
||||
FROM quota2
|
||||
WHERE username LIKE %s""",
|
||||
(f"%@{domain_name}",)
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
quota_usage[row["username"]] = int(row["used_mb"] or 0)
|
||||
|
||||
result.append({
|
||||
"name": domain_name,
|
||||
"active": bool(dom["active"]),
|
||||
"max_accounts": int(dom["mailboxes"]), # 0 = unlimited
|
||||
"max_quota_mb": int(dom["quota"]), # total domain quota MB
|
||||
"accounts": [
|
||||
{
|
||||
"username": m["local_part"],
|
||||
"email": m["username"], # full user@domain
|
||||
"name": m["name"] or "",
|
||||
"quota_mb": int(m["quota_mb"] or 0),
|
||||
"used_mb": quota_usage.get(m["username"], 0),
|
||||
"active": bool(m["active"]),
|
||||
}
|
||||
for m in mailboxes
|
||||
],
|
||||
"aliases": [
|
||||
{
|
||||
"address": a["address"],
|
||||
"goto": a["goto"],
|
||||
"active": bool(a["active"]),
|
||||
}
|
||||
for a in aliases
|
||||
],
|
||||
})
|
||||
|
||||
return {"success": True, "domains": result}
|
||||
except Exception as e:
|
||||
log.error(f"get_state error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
HANDLERS = {
|
||||
"apply_domain": handle_apply_domain,
|
||||
"delete_domain": handle_delete_domain,
|
||||
"get_quota_usage": handle_get_quota_usage,
|
||||
"get_state": handle_get_state,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Main loop
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def run(cfg: dict):
|
||||
redis_kwargs = {
|
||||
"host": cfg["redis_host"],
|
||||
"port": cfg["redis_port"],
|
||||
"db": cfg["redis_db"],
|
||||
"decode_responses": True,
|
||||
}
|
||||
if cfg.get("redis_password"):
|
||||
redis_kwargs["password"] = cfg["redis_password"]
|
||||
tls_cfg = cfg.get("tls", {})
|
||||
if tls_cfg.get("enabled", False):
|
||||
redis_kwargs["ssl"] = True
|
||||
redis_kwargs["ssl_certfile"] = tls_cfg["client_cert"]
|
||||
redis_kwargs["ssl_keyfile"] = tls_cfg["client_key"]
|
||||
redis_kwargs["ssl_ca_certs"] = tls_cfg["ca_cert"]
|
||||
redis_kwargs["ssl_cert_reqs"] = "required"
|
||||
log.info("Redis mTLS enabled")
|
||||
else:
|
||||
log.warning("Redis TLS disabled — traffic is unencrypted")
|
||||
|
||||
# Test DB connection on startup
|
||||
try:
|
||||
conn = get_db(cfg)
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) AS n FROM domain")
|
||||
n = cur.fetchone()["n"]
|
||||
log.info(f"MariaDB connected — {n} domain(s) in database")
|
||||
except Exception as e:
|
||||
log.error(f"Cannot connect to MariaDB: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
r = redis.Redis(**redis_kwargs)
|
||||
r.ping()
|
||||
log.info(f"Connected to Redis at {cfg['redis_host']}:{cfg['redis_port']}")
|
||||
|
||||
pubsub = r.pubsub()
|
||||
pubsub.subscribe("mail.commands")
|
||||
log.info("Subscribed to mail.commands — waiting for messages…")
|
||||
# Announce reconnection so the backend can flush the pending queue
|
||||
import socket as _socket
|
||||
r.publish("agent.ready", json.dumps({"service": "mail", "host": _socket.gethostname()}))
|
||||
log.info(f"Published agent.ready for service 'mail'")
|
||||
|
||||
for message in pubsub.listen():
|
||||
if message["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(message["data"])
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
log.warning("Malformed message received, ignored")
|
||||
continue
|
||||
|
||||
# mTLS handles authentication — no token check needed
|
||||
# Only clients with a valid CA-signed cert can connect to Redis
|
||||
|
||||
msg_id = msg.get("id", "unknown")
|
||||
action = msg.get("action", "")
|
||||
payload = msg.get("payload", {})
|
||||
|
||||
log.info(f"Received action '{action}' (id={msg_id})")
|
||||
|
||||
handler = HANDLERS.get(action)
|
||||
if not handler:
|
||||
ack = {"id": msg_id, "success": False, "error": f"Unknown action '{action}'"}
|
||||
else:
|
||||
ack = handler(payload, cfg)
|
||||
ack["id"] = msg_id
|
||||
|
||||
r.publish("mail.acks", json.dumps(ack))
|
||||
log.info(f"ACK for '{action}' (id={msg_id}): success={ack.get('success')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="NetAdmin Mail Agent (PostfixAdmin/MySQL)")
|
||||
parser.add_argument("--config", default="/etc/netadmin/config.yaml")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not Path(args.config).exists():
|
||||
log.error(f"Config file not found: {args.config}")
|
||||
sys.exit(1)
|
||||
|
||||
agent_cfg = {**DEFAULT_CFG, **load_config(args.config)}
|
||||
log.info(
|
||||
f"Mail agent starting "
|
||||
f"(db={agent_cfg['db_user']}@{agent_cfg['db_host']}/{agent_cfg['db_name']})"
|
||||
)
|
||||
|
||||
try:
|
||||
run(agent_cfg)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Mail agent stopped")
|
||||
except Exception as e:
|
||||
log.error(f"Fatal error: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=NetAdmin DNS Agent
|
||||
After=network.target named.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bind
|
||||
Group=bind
|
||||
ExecStart=/usr/bin/python3 /opt/netadmin-agent/dns_agent.py --config /etc/netadmin/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=netadmin-dns-agent
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/bind/zones /etc/bind/named.conf.local
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NetAdmin Mail Agent
|
||||
After=network.target postfix.service dovecot.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/usr/bin/python3 /opt/netadmin-agent/mail_agent.py --config /etc/netadmin/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=netadmin-mail-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,4 @@
|
||||
redis>=5.0
|
||||
pyyaml>=6.0
|
||||
dnspython>=2.6.0 # dns_agent only
|
||||
PyMySQL>=1.1.0 # mail_agent only
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
audit.py — Centralized audit log for NetAdmin
|
||||
|
||||
All tables live in the shared MariaDB database (cfg.database.name).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from db import get_db as _conn
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _init_tables():
|
||||
"""Create audit and sync_history tables if they don't exist."""
|
||||
try:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
ts DATETIME NOT NULL DEFAULT NOW(),
|
||||
user_id VARCHAR(36),
|
||||
username VARCHAR(64),
|
||||
ip VARCHAR(45),
|
||||
service VARCHAR(16) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
target VARCHAR(255),
|
||||
detail TEXT,
|
||||
success TINYINT NOT NULL DEFAULT 1,
|
||||
error TEXT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
for idx_name, idx_col in [
|
||||
("idx_audit_ts", "ts"),
|
||||
("idx_audit_user", "user_id"),
|
||||
("idx_audit_service", "service"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"CREATE INDEX {idx_name} ON audit_log({idx_col})")
|
||||
except Exception:
|
||||
pass # index already exists
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] init_tables: {e}")
|
||||
|
||||
|
||||
_init_tables()
|
||||
|
||||
|
||||
def record(
|
||||
service: str,
|
||||
action: str,
|
||||
target: str | None = None,
|
||||
detail: dict | None = None,
|
||||
success: bool = True,
|
||||
error: str | None = None,
|
||||
user: dict | None = None, # FastAPI user dict from get_current_user
|
||||
ip: str | None = None,
|
||||
) -> str:
|
||||
"""Write one audit entry. Returns the entry id."""
|
||||
entry_id = str(uuid.uuid4())
|
||||
user_id = user.get("id") if user else None
|
||||
username = user.get("username") if user else "system"
|
||||
try:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO audit_log
|
||||
(id, user_id, username, ip, service, action, target, detail, success, error)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
entry_id,
|
||||
user_id,
|
||||
username,
|
||||
ip,
|
||||
service,
|
||||
action,
|
||||
target,
|
||||
json.dumps(detail) if detail else None,
|
||||
int(success),
|
||||
error,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# Audit must never break the main flow
|
||||
log.warning(f"[audit] Failed to write log entry: {e}")
|
||||
return entry_id
|
||||
|
||||
|
||||
def query(
|
||||
service: str | None = None,
|
||||
username: str | None = None,
|
||||
action: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Query audit log entries, newest first."""
|
||||
conditions = []
|
||||
params = []
|
||||
if service:
|
||||
conditions.append("service = %s"); params.append(service)
|
||||
if username:
|
||||
conditions.append("username LIKE %s"); params.append(f"%{username}%")
|
||||
if action:
|
||||
conditions.append("action = %s"); params.append(action)
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params += [limit, offset]
|
||||
|
||||
try:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM audit_log {where} ORDER BY ts DESC LIMIT %s OFFSET %s",
|
||||
params
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] Query failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def count(
|
||||
service: str | None = None,
|
||||
username: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> int:
|
||||
conditions = []
|
||||
params = []
|
||||
if service:
|
||||
conditions.append("service = %s"); params.append(service)
|
||||
if username:
|
||||
conditions.append("username LIKE %s"); params.append(f"%{username}%")
|
||||
if action:
|
||||
conditions.append("action = %s"); params.append(action)
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
try:
|
||||
with _conn() as conn:
|
||||
return conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM audit_log {where}", params
|
||||
).fetchone()["n"]
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] Count failed: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Sync history
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _ensure_sync_history():
|
||||
"""Create sync_history table if not exists."""
|
||||
try:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sync_history (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
ts DATETIME NOT NULL DEFAULT NOW(),
|
||||
service VARCHAR(16) NOT NULL,
|
||||
`trigger` VARCHAR(32) NOT NULL,
|
||||
duration_ms INT,
|
||||
zones INT DEFAULT 0,
|
||||
records INT DEFAULT 0,
|
||||
success TINYINT NOT NULL DEFAULT 1,
|
||||
error TEXT,
|
||||
detail TEXT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
try:
|
||||
conn.execute("CREATE INDEX idx_synchist_ts ON sync_history(ts)")
|
||||
except Exception:
|
||||
pass # index already exists
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] sync_history init failed: {e}")
|
||||
|
||||
|
||||
_ensure_sync_history()
|
||||
|
||||
|
||||
def record_sync(
|
||||
service: str,
|
||||
trigger: str,
|
||||
duration_ms: int | None = None,
|
||||
zones: int = 0,
|
||||
records: int = 0,
|
||||
success: bool = True,
|
||||
error: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> str:
|
||||
"""Record one sync operation in sync_history."""
|
||||
entry_id = str(uuid.uuid4())
|
||||
try:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO sync_history
|
||||
(id, service, `trigger`, duration_ms, zones, records, success, error, detail)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
entry_id, service, trigger,
|
||||
duration_ms, zones, records,
|
||||
int(success), error,
|
||||
json.dumps(detail) if detail else None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] Failed to write sync_history: {e}")
|
||||
return entry_id
|
||||
|
||||
|
||||
def query_sync_history(
|
||||
service: str | None = None,
|
||||
trigger: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Return sync history entries, newest first."""
|
||||
conditions, params = [], []
|
||||
if service:
|
||||
conditions.append("service=%s"); params.append(service)
|
||||
if trigger:
|
||||
conditions.append("`trigger`=%s"); params.append(trigger)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params += [limit, offset]
|
||||
try:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM sync_history {where} ORDER BY ts DESC LIMIT %s OFFSET %s",
|
||||
params
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] sync_history query failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def count_sync_history(service: str | None = None) -> int:
|
||||
conditions, params = [], []
|
||||
if service:
|
||||
conditions.append("service=%s"); params.append(service)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
try:
|
||||
with _conn() as conn:
|
||||
return conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM sync_history {where}", params
|
||||
).fetchone()["n"]
|
||||
except Exception as e:
|
||||
log.warning(f"[audit] sync_history count failed: {e}")
|
||||
return 0
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Pending queue: if an agent is unreachable, actions are stored in SQLite
|
||||
(table: pending_actions) and can be retried later via /pending endpoints.
|
||||
|
||||
broker.py — Redis mTLS message broker for NetAdmin
|
||||
|
||||
All connections use mutual TLS (mTLS):
|
||||
- Backend presents backend.crt signed by the internal CA
|
||||
- Redis server presents redis-server.crt signed by the same CA
|
||||
- Both sides verify each other's certificate
|
||||
|
||||
Channels:
|
||||
dns.commands / dns.acks
|
||||
mail.commands / mail.acks
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import redis
|
||||
import redis.connection
|
||||
|
||||
from config import cfg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Pending action queue (SQLite — shared netadmin.db)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
from db import get_db as _queue_conn
|
||||
|
||||
|
||||
def _init_queue_table():
|
||||
try:
|
||||
with _queue_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pending_actions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
service TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
except Exception as e:
|
||||
log.warning(f"[broker] queue table init: {e}")
|
||||
|
||||
|
||||
_init_queue_table()
|
||||
|
||||
|
||||
|
||||
def queue_action(service: str, action: str, payload: dict) -> str:
|
||||
"""Store a failed action for later retry. Returns the queue entry id."""
|
||||
entry_id = str(uuid.uuid4())
|
||||
with _queue_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pending_actions(id,service,action,payload,error) VALUES(%s,%s,%s,%s,%s)",
|
||||
(entry_id, service, action, json.dumps(payload),
|
||||
"Agent unreachable at " + datetime.datetime.utcnow().isoformat())
|
||||
)
|
||||
log.warning(f"[queue] Action '{action}' ({service}) queued (id={entry_id})")
|
||||
return entry_id
|
||||
|
||||
|
||||
def list_pending() -> list:
|
||||
"""Return all non-done pending actions ordered by creation date."""
|
||||
with _queue_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM pending_actions WHERE status != 'done' ORDER BY created_at"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_done(entry_id: str):
|
||||
with _queue_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pending_actions SET status='done', updated_at=NOW() WHERE id=%s",
|
||||
(entry_id,)
|
||||
)
|
||||
|
||||
|
||||
def mark_failed(entry_id: str, error: str):
|
||||
with _queue_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pending_actions SET status='failed', error=%s, updated_at=NOW() WHERE id=%s",
|
||||
(error, entry_id)
|
||||
)
|
||||
|
||||
|
||||
def get_redis() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
|
||||
kwargs: dict = {
|
||||
"host": cfg.redis.host,
|
||||
"port": cfg.redis.port,
|
||||
"db": cfg.redis.db,
|
||||
"decode_responses": True,
|
||||
"socket_timeout": cfg.redis.ack_timeout + 2,
|
||||
"socket_connect_timeout": 5,
|
||||
}
|
||||
|
||||
if cfg.redis.password:
|
||||
kwargs["password"] = cfg.redis.password
|
||||
|
||||
tls = cfg.redis.tls
|
||||
if tls.enabled:
|
||||
kwargs["ssl"] = True
|
||||
kwargs["ssl_certfile"] = tls.client_cert
|
||||
kwargs["ssl_keyfile"] = tls.client_key
|
||||
kwargs["ssl_ca_certs"] = tls.ca_cert
|
||||
kwargs["ssl_cert_reqs"] = "required"
|
||||
log.info(
|
||||
f"[broker] Redis mTLS enabled "
|
||||
f"(cert={tls.client_cert}, ca={tls.ca_cert})"
|
||||
)
|
||||
else:
|
||||
log.warning("[broker] Redis TLS disabled — traffic is unencrypted")
|
||||
|
||||
_redis_client = redis.Redis(**kwargs)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def publish_and_wait(channel: str, ack_channel: str,
|
||||
action: str, payload: dict,
|
||||
timeout: int | None = None) -> dict:
|
||||
"""Publish a command and block until the agent ACKs or timeout.
|
||||
|
||||
Both the publish connection and the subscribe connection use the same
|
||||
mTLS configuration.
|
||||
|
||||
Returns ACK dict: {"id": ..., "success": True/False, "error": "..."}
|
||||
Raises RuntimeError on timeout or connection error.
|
||||
"""
|
||||
r = get_redis()
|
||||
msg_id = str(uuid.uuid4())
|
||||
timeout = timeout or cfg.redis.ack_timeout
|
||||
|
||||
message = json.dumps({
|
||||
"id": msg_id,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
# No token — authentication is handled by the mTLS certificate CN
|
||||
})
|
||||
|
||||
# Build a separate subscribe connection with mTLS
|
||||
sub_kwargs: dict = {
|
||||
"host": cfg.redis.host,
|
||||
"port": cfg.redis.port,
|
||||
"db": cfg.redis.db,
|
||||
"decode_responses": True,
|
||||
}
|
||||
if cfg.redis.password:
|
||||
sub_kwargs["password"] = cfg.redis.password
|
||||
tls = cfg.redis.tls
|
||||
if tls.enabled:
|
||||
sub_kwargs["ssl"] = True
|
||||
sub_kwargs["ssl_certfile"] = tls.client_cert
|
||||
sub_kwargs["ssl_keyfile"] = tls.client_key
|
||||
sub_kwargs["ssl_ca_certs"] = tls.ca_cert
|
||||
sub_kwargs["ssl_cert_reqs"] = "required"
|
||||
|
||||
sub_r = redis.Redis(**sub_kwargs)
|
||||
pubsub = sub_r.pubsub()
|
||||
pubsub.subscribe(ack_channel)
|
||||
|
||||
try:
|
||||
# Drain any stale subscribe confirmation message
|
||||
pubsub.get_message(timeout=0.1)
|
||||
|
||||
r.publish(channel, message)
|
||||
log.debug(f"[broker] published '{action}' → {channel} (id={msg_id})")
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
msg = pubsub.get_message(timeout=min(remaining, 0.5))
|
||||
if msg is None or msg["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
ack = json.loads(msg["data"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if ack.get("id") == msg_id:
|
||||
log.debug(f"[broker] ACK for {msg_id}: success={ack.get('success')}")
|
||||
return ack
|
||||
|
||||
finally:
|
||||
try:
|
||||
pubsub.unsubscribe()
|
||||
pubsub.close()
|
||||
sub_r.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise RuntimeError(
|
||||
f"Agent timeout ({timeout}s) for action '{action}'. "
|
||||
"Check that the agent is running and connected to Redis."
|
||||
)
|
||||
|
||||
|
||||
def publish_or_queue(service: str, action: str, payload: dict) -> dict:
|
||||
"""Try to publish and wait for ACK. If agent is unreachable, queue the action.
|
||||
|
||||
Returns:
|
||||
{"success": True, "queued": False} — agent responded OK
|
||||
{"success": True, "queued": True, "id": "..."} — queued for later
|
||||
Raises HTTPException on agent error (agent reachable but returned failure).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
channel = f"{service}.commands"
|
||||
ack_channel = f"{service}.acks"
|
||||
try:
|
||||
ack = publish_and_wait(channel, ack_channel, action, payload)
|
||||
if not ack.get("success"):
|
||||
raise HTTPException(500, f"Agent error: {ack.get('error', '')}")
|
||||
return {"success": True, "queued": False}
|
||||
except RuntimeError:
|
||||
# Agent unreachable — queue for later
|
||||
entry_id = queue_action(service, action, payload)
|
||||
return {"success": True, "queued": True, "id": entry_id}
|
||||
|
||||
|
||||
def retry_pending(entry_id: str) -> dict:
|
||||
"""Retry a queued action. Returns result dict."""
|
||||
with _queue_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pending_actions WHERE id=%s", (entry_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"success": False, "error": "Not found"}
|
||||
|
||||
conn2_ctx = _queue_conn()
|
||||
with conn2_ctx as conn:
|
||||
conn.execute(
|
||||
"UPDATE pending_actions SET status='retrying', updated_at=NOW() WHERE id=%s",
|
||||
(entry_id,)
|
||||
)
|
||||
|
||||
service = row["service"]
|
||||
action = row["action"]
|
||||
payload = json.loads(row["payload"])
|
||||
channel = f"{service}.commands"
|
||||
ack_channel = f"{service}.acks"
|
||||
|
||||
try:
|
||||
ack = publish_and_wait(channel, ack_channel, action, payload)
|
||||
if ack.get("success"):
|
||||
mark_done(entry_id)
|
||||
return {"success": True, "queued": False}
|
||||
else:
|
||||
err = ack.get("error", "Unknown error")
|
||||
mark_failed(entry_id, err)
|
||||
return {"success": False, "error": err}
|
||||
except RuntimeError as e:
|
||||
mark_failed(entry_id, str(e))
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def flush_pending_for_service(service: str):
|
||||
"""Retry all pending actions for a given service (called when agent reconnects)."""
|
||||
with _queue_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM pending_actions WHERE service=%s AND status != 'done' ORDER BY created_at",
|
||||
(service,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.info(f"[queue] No pending actions for service '{service}'")
|
||||
return
|
||||
log.info(f"[queue] Flushing {len(rows)} pending action(s) for '{service}'")
|
||||
for row in rows:
|
||||
result = retry_pending(row["id"])
|
||||
if result.get("success"):
|
||||
log.info(f"[queue] Flushed action {row['id']} ({row['action']})")
|
||||
else:
|
||||
log.warning(f"[queue] Failed to flush {row['id']}: {result.get('error')}")
|
||||
|
||||
|
||||
def publish_dns(action: str, payload: dict) -> dict:
|
||||
return publish_and_wait("dns.commands", "dns.acks", action, payload)
|
||||
|
||||
|
||||
def publish_mail(action: str, payload: dict) -> dict:
|
||||
return publish_and_wait("mail.commands", "mail.acks", action, payload)
|
||||
|
||||
|
||||
def check_connection() -> bool:
|
||||
try:
|
||||
get_redis().ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
config.py — Chargement centralisé de la configuration NetAdmin
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
try:
|
||||
import yaml
|
||||
_HAS_YAML = True
|
||||
except ImportError:
|
||||
_HAS_YAML = False
|
||||
print("[WARN] PyYAML not installed — using defaults. Run: pip install pyyaml")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
cors_origins: List[str] = field(default_factory=lambda: ["*"])
|
||||
behind_proxy: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisTLSConfig:
|
||||
enabled: bool = True
|
||||
ca_cert: str = "/etc/netadmin/certs/ca.crt"
|
||||
client_cert: str = "/etc/netadmin/certs/backend.crt"
|
||||
client_key: str = "/etc/netadmin/certs/backend.key"
|
||||
check_hostname: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisConfig:
|
||||
host: str = "localhost"
|
||||
port: int = 6380
|
||||
password: str = ""
|
||||
db: int = 0
|
||||
ack_timeout: int = 10
|
||||
tls: RedisTLSConfig = field(default_factory=RedisTLSConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmtpConfig:
|
||||
enabled: bool = False
|
||||
host: str = "localhost"
|
||||
port: int = 587
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
from_addr: str = "netadmin@localhost"
|
||||
from_name: str = "NetAdmin"
|
||||
use_tls: bool = True # STARTTLS
|
||||
use_ssl: bool = False # SSL/TLS direct (port 465)
|
||||
# Notification triggers
|
||||
notify_login: bool = True # send email on successful login
|
||||
notify_role_added: bool = True # send email when a role is assigned
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncConfig:
|
||||
interval: int = 300 # seconds between polls, 0 = disabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
host: str = "localhost"
|
||||
port: int = 3306
|
||||
user: str = "netadmin"
|
||||
password: str = ""
|
||||
name: str = "netadmin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailConfig:
|
||||
pass # mail data stored in shared netadmin.db
|
||||
|
||||
|
||||
@dataclass
|
||||
class OidcConfig:
|
||||
enabled: bool = False
|
||||
issuer: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
scopes: List[str] = field(default_factory=lambda: ["openid", "email", "profile"])
|
||||
auto_provision: bool = True
|
||||
redirect_uri: str = "http://localhost:3000/auth/callback"
|
||||
use_pkce: bool = True
|
||||
admin_group: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
session_secret: str = ""
|
||||
session_ttl_hours: int = 8
|
||||
oidc: OidcConfig = field(default_factory=OidcConfig)
|
||||
# db_path removed — auth tables live in the shared netadmin.db
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
redis: RedisConfig = field(default_factory=RedisConfig)
|
||||
sync: SyncConfig = field(default_factory=SyncConfig)
|
||||
smtp: SmtpConfig = field(default_factory=SmtpConfig)
|
||||
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||
mail: MailConfig = field(default_factory=MailConfig)
|
||||
auth: AuthConfig = field(default_factory=AuthConfig)
|
||||
|
||||
|
||||
def _merge(obj, data: dict):
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
for key, value in data.items():
|
||||
if not hasattr(obj, key):
|
||||
continue
|
||||
current = getattr(obj, key)
|
||||
if hasattr(current, '__dataclass_fields__'):
|
||||
_merge(current, value)
|
||||
else:
|
||||
setattr(obj, key, value)
|
||||
|
||||
|
||||
def _find_config_file():
|
||||
for path in [
|
||||
os.environ.get("NETADMIN_CONFIG", ""),
|
||||
"/etc/netadmin/config.yaml",
|
||||
str(Path(__file__).parent / "config.yaml"),
|
||||
]:
|
||||
if path and Path(path).exists():
|
||||
return Path(path)
|
||||
return None
|
||||
|
||||
|
||||
def _load() -> AppConfig:
|
||||
config = AppConfig()
|
||||
config_file = _find_config_file()
|
||||
if config_file is None:
|
||||
print("[INFO] No config.yaml found — using defaults")
|
||||
return _finalise(config)
|
||||
print(f"[INFO] Loading configuration from: {config_file}")
|
||||
if not _HAS_YAML:
|
||||
return _finalise(config)
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
_merge(config, raw)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to parse {config_file}: {e}")
|
||||
return _finalise(config)
|
||||
|
||||
|
||||
def _finalise(config: AppConfig) -> AppConfig:
|
||||
if not config.auth.session_secret:
|
||||
config.auth.session_secret = secrets.token_hex(32)
|
||||
print("[INFO] session_secret not set — generated a random one (sessions won't survive restarts)")
|
||||
return config
|
||||
|
||||
|
||||
cfg: AppConfig = _load()
|
||||
@@ -0,0 +1,127 @@
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# NetAdmin — fichier de configuration
|
||||
# Emplacement par défaut : /etc/netadmin/config.yaml
|
||||
# Surcharge via : NETADMIN_CONFIG=/chemin/config.yaml
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Serveur ─────────────────────────────────────────────────────────
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
cors_origins:
|
||||
- "http://localhost:3000"
|
||||
behind_proxy: false
|
||||
|
||||
# ── Redis (broker de messages) ──────────────────────────────────────
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6380 # port TLS Redis (6380 = convention TLS)
|
||||
password: ""
|
||||
db: 0
|
||||
ack_timeout: 10 # secondes d'attente de l'ACK agent
|
||||
|
||||
# mTLS — certificats pour le backend (client Redis)
|
||||
tls:
|
||||
enabled: true
|
||||
ca_cert: "/etc/netadmin/certs/ca.crt"
|
||||
client_cert: "/etc/netadmin/certs/backend.crt"
|
||||
client_key: "/etc/netadmin/certs/backend.key"
|
||||
# Vérification du hostname du serveur Redis
|
||||
check_hostname: true
|
||||
|
||||
# ── Notifications email ──────────────────────────────────────────────
|
||||
smtp:
|
||||
enabled: false
|
||||
host: "smtp.example.com"
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from_addr: "netadmin@example.com"
|
||||
from_name: "NetAdmin"
|
||||
use_tls: true # STARTTLS (port 587)
|
||||
use_ssl: false # SSL direct (port 465) — use_tls doit être false
|
||||
# Déclencheurs de notification
|
||||
notify_login: true # email à chaque connexion réussie
|
||||
notify_role_added: true # email lors de l'ajout d'un rôle
|
||||
|
||||
# ── Synchronisation agent ───────────────────────────────────────────
|
||||
sync:
|
||||
# Intervalle de polling MariaDB → SQLite (secondes, 0 = désactivé)
|
||||
interval: 300 # 5 minutes
|
||||
|
||||
# ── Base de données (metadata) ──────────────────────────────────────
|
||||
# ── Base de données MariaDB ───────────────────────────────────────────
|
||||
# Toutes les tables (auth, DNS, mail, audit, sync) dans la même base
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
user: "netadmin"
|
||||
password: "changeme"
|
||||
name: "netadmin"
|
||||
|
||||
# ── Mail ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Authentification ─────────────────────────────────────────────────
|
||||
session_secret: ""
|
||||
session_ttl_hours: 8
|
||||
|
||||
oidc:
|
||||
enabled: false
|
||||
issuer: ""
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
redirect_uri: "http://localhost:3000/auth/callback"
|
||||
use_pkce: true
|
||||
auto_provision: true
|
||||
admin_group: ""
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
- "profile"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Configuration des AGENTS (lue par dns_agent.py / mail_agent.py)
|
||||
# Ces sections ne sont PAS lues par le backend
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Agent DNS (serveur BIND) ────────────────────────────────────────
|
||||
dns_agent:
|
||||
zones_dir: "/etc/bind/zones"
|
||||
named_conf_local: "/etc/bind/named.conf.local"
|
||||
rndc_cmd: "rndc"
|
||||
redis_host: "netadmin.example.com"
|
||||
redis_port: 6380
|
||||
redis_password: ""
|
||||
redis_db: 0
|
||||
tls:
|
||||
enabled: true
|
||||
ca_cert: "/etc/netadmin/certs/ca.crt"
|
||||
client_cert: "/etc/netadmin/certs/dns-agent.crt"
|
||||
client_key: "/etc/netadmin/certs/dns-agent.key"
|
||||
check_hostname: true
|
||||
|
||||
# ── Agent Mail (serveur Postfix/Dovecot) ────────────────────────────
|
||||
mail_agent:
|
||||
# ── MariaDB (base PostfixAdmin existante) ──────────────────────
|
||||
db_host: "localhost"
|
||||
db_port: 3306
|
||||
db_name: "postfix"
|
||||
db_user: "postfix"
|
||||
db_password: "mot_de_passe_db"
|
||||
|
||||
# ── Postfix ─────────────────────────────────────────────────────
|
||||
postfix_reload_cmd: "postfix reload"
|
||||
|
||||
# ── Redis ───────────────────────────────────────────────────────
|
||||
redis_host: "netadmin.example.com"
|
||||
redis_port: 6380
|
||||
redis_password: ""
|
||||
redis_db: 0
|
||||
|
||||
# ── mTLS ────────────────────────────────────────────────────────
|
||||
tls:
|
||||
enabled: true
|
||||
ca_cert: "/etc/netadmin/certs/ca.crt"
|
||||
client_cert: "/etc/netadmin/certs/mail-agent.crt"
|
||||
client_key: "/etc/netadmin/certs/mail-agent.key"
|
||||
check_hostname: true
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
db.py — MySQL/MariaDB connection for NetAdmin (PyMySQL).
|
||||
All tables live in the shared 'netadmin' database.
|
||||
Rows returned as dicts. Interface compatible with sqlite3 usage patterns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
|
||||
from config import cfg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ConnWrapper:
|
||||
"""
|
||||
Thin wrapper around a PyMySQL connection that adds .execute() / .fetchone()
|
||||
/ .fetchall() methods directly on the connection object — matching the sqlite3
|
||||
interface used throughout the codebase.
|
||||
"""
|
||||
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
self._cur = conn.cursor()
|
||||
|
||||
# ── Cursor proxy ─────────────────────────────────────────────────────
|
||||
def execute(self, sql: str, params=None):
|
||||
self._cur.execute(sql, params or ())
|
||||
return self._cur
|
||||
|
||||
def fetchone(self):
|
||||
return self._cur.fetchone()
|
||||
|
||||
def fetchall(self):
|
||||
return self._cur.fetchall()
|
||||
|
||||
# ── Transaction proxy ─────────────────────────────────────────────────
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _connect() -> _ConnWrapper:
|
||||
db = cfg.database
|
||||
raw = pymysql.connect(
|
||||
host = db.host,
|
||||
port = db.port,
|
||||
user = db.user,
|
||||
password = db.password,
|
||||
database = db.name,
|
||||
charset = "utf8mb4",
|
||||
cursorclass = pymysql.cursors.DictCursor,
|
||||
autocommit = False,
|
||||
)
|
||||
return _ConnWrapper(raw)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Yield a connection wrapper. Commits on success, rolls back on exception."""
|
||||
conn = _connect()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Datetime helper ────────────────────────────────────────────────────────
|
||||
|
||||
def _dt(v) -> str | None:
|
||||
"""Convert datetime/date → ISO string. Pass str/None through unchanged.
|
||||
|
||||
MariaDB returns DATETIME columns as datetime.datetime objects via PyMySQL.
|
||||
Pydantic models expect str for these fields.
|
||||
"""
|
||||
import datetime as _datetime
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (_datetime.datetime, _datetime.date)):
|
||||
return v.isoformat()
|
||||
return str(v) if v else None
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
NetAdmin — DNS & Mail Manager Backend
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import uvicorn
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from config import cfg
|
||||
from broker import check_connection
|
||||
import audit
|
||||
from contextvars import ContextVar
|
||||
|
||||
# ── Request context (IP) ─────────────────────────────────────────────────
|
||||
_request_ip: ContextVar[str] = ContextVar("request_ip", default="")
|
||||
|
||||
|
||||
def get_request_ip() -> str:
|
||||
return _request_ip.get()
|
||||
|
||||
|
||||
class IPMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||
if not ip:
|
||||
ip = request.client.host if request.client else ""
|
||||
token = _request_ip.set(ip)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
_request_ip.reset(token)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Sync helpers (run in thread pool — sync_from_agent is blocking)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _do_sync(label: str):
|
||||
"""Run a full sync from both agents. Called from executor to avoid blocking."""
|
||||
# ── DNS sync ─────────────────────────────────────────────────────
|
||||
try:
|
||||
from routers.dns import sync_from_agent as dns_sync
|
||||
r = dns_sync(trigger=label)
|
||||
log.info(
|
||||
f"[sync:{label}] DNS OK — "
|
||||
f"{r['total_zones']} zone(s), "
|
||||
f"{r['imported_zones']} importée(s), "
|
||||
f"{r['imported_records']} enreg."
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"[sync:{label}] DNS failed (agent may be offline): {e}")
|
||||
|
||||
# ── Mail sync ─────────────────────────────────────────────────────
|
||||
try:
|
||||
from routers.mail import sync_from_agent as mail_sync
|
||||
r = mail_sync(trigger=label)
|
||||
log.info(
|
||||
f"[sync:{label}] Mail OK — "
|
||||
f"{r['total_domains']} domaine(s), "
|
||||
f"{r['accounts']} nouveau(x) compte(s)"
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"[sync:{label}] Mail failed (agent may be offline): {e}")
|
||||
|
||||
|
||||
async def _agent_ready_listener():
|
||||
"""Listen for agent.ready messages and flush the pending queue."""
|
||||
import json as _json
|
||||
from broker import get_redis, flush_pending_for_service
|
||||
try:
|
||||
r = get_redis()
|
||||
sub_kwargs = {
|
||||
"host": cfg.redis.host, "port": cfg.redis.port,
|
||||
"db": cfg.redis.db, "decode_responses": True,
|
||||
}
|
||||
if cfg.redis.password:
|
||||
sub_kwargs["password"] = cfg.redis.password
|
||||
tls = cfg.redis.tls
|
||||
if tls.enabled:
|
||||
sub_kwargs.update({
|
||||
"ssl": True,
|
||||
"ssl_certfile": tls.client_cert,
|
||||
"ssl_keyfile": tls.client_key,
|
||||
"ssl_ca_certs": tls.ca_cert,
|
||||
"ssl_cert_reqs": "required",
|
||||
})
|
||||
import redis as _redis
|
||||
sub_r = _redis.Redis(**sub_kwargs)
|
||||
pubsub = sub_r.pubsub()
|
||||
pubsub.subscribe("agent.ready")
|
||||
log.info("[agent-listener] Subscribed to agent.ready")
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
msg = await loop.run_in_executor(None, lambda: pubsub.get_message(timeout=1.0))
|
||||
if msg and msg["type"] == "message":
|
||||
try:
|
||||
data = _json.loads(msg["data"])
|
||||
service = data.get("service")
|
||||
if service in ("dns", "mail"):
|
||||
log.info(f"[agent-listener] Agent '{service}' reconnected — flushing queue")
|
||||
audit.record("system", "agent_reconnect",
|
||||
target=f"agent:{service}", detail=data)
|
||||
await loop.run_in_executor(None, flush_pending_for_service, service)
|
||||
except Exception as e:
|
||||
log.warning(f"[agent-listener] Error processing agent.ready: {e}")
|
||||
await asyncio.sleep(0)
|
||||
except Exception as e:
|
||||
log.warning(f"[agent-listener] Listener error: {e}")
|
||||
|
||||
|
||||
async def _sync_once(label: str):
|
||||
"""Async wrapper: run blocking sync in thread pool."""
|
||||
if not check_connection():
|
||||
log.warning(f"[sync:{label}] Redis not reachable — skipped")
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _do_sync, label)
|
||||
|
||||
|
||||
async def _polling_loop():
|
||||
"""Background task: sync from MariaDB every cfg.sync.interval seconds."""
|
||||
interval = cfg.sync.interval
|
||||
if interval <= 0:
|
||||
log.info("[polling] Disabled (sync.interval=0)")
|
||||
return
|
||||
|
||||
log.info(f"[polling] Started — interval={interval}s")
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
await _sync_once("poll")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Lifespan: startup sync + launch polling task
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 1. Initial sync on startup
|
||||
await _sync_once("startup") # trigger="startup" passed to sync functions
|
||||
|
||||
# 2. Launch background polling task
|
||||
poll_task = None
|
||||
listener_task = None
|
||||
if cfg.sync.interval > 0:
|
||||
poll_task = asyncio.create_task(_polling_loop())
|
||||
log.info(f"[startup] Background polling task started (every {cfg.sync.interval}s)")
|
||||
else:
|
||||
log.info("[startup] Background polling disabled (sync.interval=0)")
|
||||
|
||||
try:
|
||||
listener_task = asyncio.create_task(_agent_ready_listener())
|
||||
log.info("[startup] Agent-ready listener started")
|
||||
except Exception as e:
|
||||
log.warning(f"[startup] Could not start agent-ready listener: {e}")
|
||||
|
||||
yield # ← application runs here
|
||||
|
||||
# 3. Shutdown: cancel tasks cleanly
|
||||
for task in filter(None, [poll_task, listener_task]):
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
log.info("[shutdown] Background tasks stopped")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# App
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="NetAdmin API",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url=None, # Disable Swagger UI — not exposed to end users
|
||||
redoc_url=None, # Disable ReDoc
|
||||
openapi_url=None, # Disable OpenAPI schema endpoint
|
||||
)
|
||||
|
||||
app.add_middleware(IPMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cfg.server.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from routers import auth, dns, mail # noqa: E402
|
||||
from routers.auth import get_current_user, require_global_admin # noqa: E402
|
||||
from db import get_db # noqa: E402
|
||||
app.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
||||
app.include_router(dns.router, prefix="/dns", tags=["DNS"])
|
||||
app.include_router(mail.router, prefix="/mail", tags=["Mail"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"redis": check_connection(),
|
||||
"sync_interval": cfg.sync.interval,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/sync/history")
|
||||
def get_global_sync_history(
|
||||
service: str | None = None,
|
||||
trigger: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user: dict = Depends(require_global_admin),
|
||||
):
|
||||
"""Combined sync history for all services — global admin only."""
|
||||
return {
|
||||
"total": audit.count_sync_history(service),
|
||||
"entries": audit.query_sync_history(service, trigger, limit, offset),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/audit")
|
||||
def get_audit_log(
|
||||
service: str | None = None,
|
||||
username: str | None = None,
|
||||
action: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user: dict = Depends(require_global_admin),
|
||||
):
|
||||
"""Audit log — global admin only."""
|
||||
return {
|
||||
"total": audit.count(service, username, action),
|
||||
"entries": audit.query(service, username, action, limit, offset),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=cfg.server.host,
|
||||
port=cfg.server.port,
|
||||
reload=False,
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
notify.py — Email notifications for NetAdmin
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import email.utils
|
||||
import logging
|
||||
import quopri
|
||||
import socket
|
||||
from email.header import Header
|
||||
from email.message import Message
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
|
||||
from config import cfg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_qp_part(content: str, subtype: str) -> Message:
|
||||
"""Build a text/plain or text/html MIME part using quoted-printable encoding.
|
||||
|
||||
MIMEText with charset='utf-8' defaults to base64.
|
||||
We encode manually as QP so mail clients display it as plain text.
|
||||
"""
|
||||
encoded = quopri.encodestring(content.encode("utf-8"), quotetabs=False)
|
||||
part = Message()
|
||||
part["Content-Type"] = f"text/{subtype}; charset=utf-8"
|
||||
part["Content-Transfer-Encoding"] = "quoted-printable"
|
||||
part.set_payload(encoded.decode("ascii"))
|
||||
return part
|
||||
|
||||
|
||||
def _send(to_addr: str, subject: str, body_text: str, body_html: Optional[str] = None):
|
||||
"""Send an email with correct headers and QP encoding."""
|
||||
s = cfg.smtp
|
||||
if not s.enabled or not to_addr:
|
||||
return
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
|
||||
# RFC-required headers
|
||||
msg["Message-ID"] = email.utils.make_msgid(domain=s.from_addr.split("@")[-1])
|
||||
msg["Date"] = email.utils.formatdate(localtime=True)
|
||||
msg["Subject"] = Header(subject, "utf-8").encode()
|
||||
msg["From"] = email.utils.formataddr((s.from_name, s.from_addr))
|
||||
msg["To"] = to_addr
|
||||
msg["X-Mailer"] = "NetAdmin"
|
||||
|
||||
# Attach parts with quoted-printable (readable in all clients)
|
||||
msg.attach(_make_qp_part(body_text, "plain"))
|
||||
if body_html:
|
||||
msg.attach(_make_qp_part(body_html, "html"))
|
||||
|
||||
if s.use_ssl:
|
||||
server = __import__("smtplib").SMTP_SSL(s.host, s.port, timeout=10)
|
||||
else:
|
||||
server = __import__("smtplib").SMTP(s.host, s.port, timeout=10)
|
||||
if s.use_tls:
|
||||
server.starttls()
|
||||
|
||||
if s.username and s.password:
|
||||
server.login(s.username, s.password)
|
||||
|
||||
server.sendmail(s.from_addr, [to_addr], msg.as_string())
|
||||
server.quit()
|
||||
log.info(f"[notify] Email sent to {to_addr}: {subject}")
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"[notify] Failed to send email to {to_addr}: {e}")
|
||||
|
||||
|
||||
def notify_login(username: str, email_addr: Optional[str], ip: str):
|
||||
if not cfg.smtp.notify_login or not email_addr:
|
||||
return
|
||||
|
||||
hostname = socket.gethostname()
|
||||
now = datetime.datetime.now().strftime("%d/%m/%Y à %H:%M")
|
||||
|
||||
subject = "Connexion à votre compte NetAdmin"
|
||||
text = (
|
||||
f"Bonjour {username},\n\n"
|
||||
f"Une connexion a été détectée sur votre compte NetAdmin.\n\n"
|
||||
f" Date : {now}\n"
|
||||
f" Adresse IP : {ip or 'inconnue'}\n"
|
||||
f" Serveur : {hostname}\n\n"
|
||||
f"Si vous n'êtes pas à l'origine de cette connexion, "
|
||||
f"contactez immédiatement votre administrateur.\n\n"
|
||||
f"-- NetAdmin"
|
||||
)
|
||||
html = f"""\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:520px;margin:0 auto">
|
||||
<h2 style="color:#0d7bc4;border-bottom:2px solid #0d7bc4;padding-bottom:8px">
|
||||
Connexion à votre compte NetAdmin
|
||||
</h2>
|
||||
<p>Bonjour <strong>{username}</strong>,</p>
|
||||
<p>Une connexion a été détectée sur votre compte NetAdmin.</p>
|
||||
<table style="border-collapse:collapse;margin:16px 0;width:100%">
|
||||
<tr style="background:#f5f5f5">
|
||||
<td style="padding:8px 12px;font-weight:bold;width:120px">Date</td>
|
||||
<td style="padding:8px 12px">{now}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:bold">Adresse IP</td>
|
||||
<td style="padding:8px 12px;font-family:monospace">{ip or 'inconnue'}</td>
|
||||
</tr>
|
||||
<tr style="background:#f5f5f5">
|
||||
<td style="padding:8px 12px;font-weight:bold">Serveur</td>
|
||||
<td style="padding:8px 12px">{hostname}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color:#c00;font-weight:bold">
|
||||
Si vous n'êtes pas à l'origine de cette connexion,
|
||||
contactez immédiatement votre administrateur.
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0">
|
||||
<p style="color:#999;font-size:11px">NetAdmin — notification automatique</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
_send(email_addr, subject, text, html)
|
||||
|
||||
|
||||
def notify_role_assigned(
|
||||
target_username: str,
|
||||
target_email: Optional[str],
|
||||
role: str,
|
||||
scopes: list,
|
||||
assigned_by: str,
|
||||
):
|
||||
if not cfg.smtp.notify_role_added or not target_email:
|
||||
return
|
||||
|
||||
scope_str = ", ".join(str(s) for s in scopes if s) if scopes and scopes != [None] else "global"
|
||||
|
||||
subject = f"NetAdmin — Nouveau rôle assigné : {role}"
|
||||
text = (
|
||||
f"Bonjour {target_username},\n\n"
|
||||
f"Un nouveau rôle vous a été assigné sur NetAdmin.\n\n"
|
||||
f" Rôle : {role}\n"
|
||||
f" Périmètre : {scope_str}\n"
|
||||
f" Assigné par : {assigned_by}\n\n"
|
||||
f"Connectez-vous à NetAdmin pour consulter vos permissions.\n\n"
|
||||
f"-- NetAdmin"
|
||||
)
|
||||
html = f"""\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:520px;margin:0 auto">
|
||||
<h2 style="color:#0d7bc4;border-bottom:2px solid #0d7bc4;padding-bottom:8px">
|
||||
Nouveau rôle NetAdmin
|
||||
</h2>
|
||||
<p>Bonjour <strong>{target_username}</strong>,</p>
|
||||
<p>Un nouveau rôle vous a été assigné.</p>
|
||||
<table style="border-collapse:collapse;margin:16px 0;width:100%">
|
||||
<tr style="background:#f5f5f5">
|
||||
<td style="padding:8px 12px;font-weight:bold;width:140px">Rôle</td>
|
||||
<td style="padding:8px 12px;font-weight:bold;color:#0d7bc4">{role}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:bold">Périmètre</td>
|
||||
<td style="padding:8px 12px;font-family:monospace">{scope_str}</td>
|
||||
</tr>
|
||||
<tr style="background:#f5f5f5">
|
||||
<td style="padding:8px 12px;font-weight:bold">Assigné par</td>
|
||||
<td style="padding:8px 12px">{assigned_by}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0">
|
||||
<p style="color:#999;font-size:11px">NetAdmin — notification automatique</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
_send(target_email, subject, text, html)
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
dnspython>=2.6.0
|
||||
pydantic[email]>=2.0.0
|
||||
pyyaml>=6.0
|
||||
redis>=5.0
|
||||
pyotp>=2.9.0
|
||||
qrcode>=7.4.2
|
||||
pillow>=10.0.0
|
||||
|
||||
PyMySQL>=1.1.0
|
||||
@@ -0,0 +1 @@
|
||||
# routers package
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
||||
"""
|
||||
DNS Router — stores zone/record metadata in SQLite, applies via DNS agent through Redis.
|
||||
|
||||
The backend never touches zone files or calls rndc directly.
|
||||
All mutations are:
|
||||
1. Validated here (Pydantic + semantic checks)
|
||||
2. Persisted in SQLite
|
||||
3. Sent to the DNS agent via Redis broker
|
||||
4. The agent applies changes and sends ACK
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import ipaddress
|
||||
import sqlite3
|
||||
import datetime
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from config import cfg
|
||||
from broker import publish_dns, publish_or_queue
|
||||
from routers.auth import get_current_user, get_allowed_dns_domains, require_dns_access
|
||||
import audit
|
||||
from main import get_request_ip
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
from db import get_db, _dt
|
||||
|
||||
VALID_RECORD_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"}
|
||||
|
||||
RE_DNS_NAME = re.compile(
|
||||
r'^(@|\*|(\*\.)?([a-zA-Z0-9_]([a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?\.)*'
|
||||
r'[a-zA-Z0-9_]([a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?\.?)$'
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Database
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def init_db():
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
name VARCHAR(253) PRIMARY KEY,
|
||||
admin VARCHAR(255) NOT NULL DEFAULT 'hostmaster',
|
||||
ttl INT NOT NULL DEFAULT 3600,
|
||||
refresh INT NOT NULL DEFAULT 43200,
|
||||
retry INT NOT NULL DEFAULT 3600,
|
||||
expire INT NOT NULL DEFAULT 2419200,
|
||||
negative_ttl INT NOT NULL DEFAULT 3600,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
last_applied_at DATETIME,
|
||||
last_apply_ok TINYINT DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS records (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
zone VARCHAR(253) NOT NULL,
|
||||
name VARCHAR(253) NOT NULL,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
ttl INT,
|
||||
value TEXT NOT NULL,
|
||||
priority INT,
|
||||
active TINYINT NOT NULL DEFAULT 1,
|
||||
pending_sync TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (zone) REFERENCES zones(name) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
# MySQL ALTER TABLE — add columns only if missing
|
||||
for col, col_type in [
|
||||
("active", "TINYINT NOT NULL DEFAULT 1"),
|
||||
("pending_sync", "TINYINT NOT NULL DEFAULT 0"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) AS n as cnt FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='records' AND COLUMN_NAME=%s",
|
||||
(col,)
|
||||
)
|
||||
row = conn.fetchone()
|
||||
if not row or row.get("cnt", 0) == 0:
|
||||
conn.execute(f"ALTER TABLE records ADD COLUMN {col} {col_type}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
init_db()
|
||||
# Migration: add active column if upgrading
|
||||
with get_db() as _mc:
|
||||
try:
|
||||
_mc.execute("ALTER TABLE records ADD COLUMN active INT NOT NULL DEFAULT 1")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_mc.execute("ALTER TABLE records ADD COLUMN pending_sync INT NOT NULL DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WARN] DNS DB init failed: {e}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Pydantic models
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class ZoneCreate(BaseModel):
|
||||
name: str = Field(..., example="example.com")
|
||||
admin: str = Field("hostmaster", example="hostmaster.example.com")
|
||||
ttl: int = Field(3600, ge=1, le=2147483647)
|
||||
refresh: int = Field(43200, ge=1, le=2147483647)
|
||||
retry: int = Field(3600, ge=1, le=2147483647)
|
||||
expire: int = Field(2419200, ge=1, le=2147483647)
|
||||
negative_ttl: int = Field(3600, ge=1, le=2147483647)
|
||||
|
||||
@field_validator("admin")
|
||||
@classmethod
|
||||
def no_at(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if "@" in v:
|
||||
raise ValueError("Admin email must not contain '@' — use a dot instead")
|
||||
return v
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_zone_name(cls, v: str) -> str:
|
||||
v = v.strip().rstrip(".")
|
||||
if not v:
|
||||
raise ValueError("Zone name cannot be empty")
|
||||
if len(v) > 253:
|
||||
raise ValueError("Zone name too long (max 253 characters)")
|
||||
labels = v.split(".")
|
||||
if len(labels) < 2:
|
||||
raise ValueError("Zone name must have at least two labels (e.g. example.com)")
|
||||
for label in labels:
|
||||
if not label:
|
||||
raise ValueError(f"Empty label in zone name '{v}'")
|
||||
if len(label) > 63:
|
||||
raise ValueError(f"Label '{label}' too long (max 63 characters)")
|
||||
if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$', label):
|
||||
raise ValueError(f"Invalid label '{label}'")
|
||||
return v
|
||||
|
||||
|
||||
class ZoneConfig(BaseModel):
|
||||
admin: str = Field("hostmaster")
|
||||
ttl: int = Field(3600, ge=1, le=2147483647)
|
||||
refresh: int = Field(43200, ge=1, le=2147483647)
|
||||
retry: int = Field(3600, ge=1, le=2147483647)
|
||||
expire: int = Field(2419200, ge=1, le=2147483647)
|
||||
negative_ttl: int = Field(3600, ge=1, le=2147483647)
|
||||
|
||||
@field_validator("admin")
|
||||
@classmethod
|
||||
def no_at(cls, v: str) -> str:
|
||||
if "@" in v:
|
||||
raise ValueError("Admin email must not contain '@'")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class ZoneInfo(BaseModel):
|
||||
name: str
|
||||
admin: str
|
||||
ttl: int
|
||||
refresh: int
|
||||
retry: int
|
||||
expire: int
|
||||
negative_ttl: int
|
||||
record_count: int = 0
|
||||
last_apply_ok: Optional[bool] = None
|
||||
last_applied_at: Optional[str] = None
|
||||
queued: bool = False
|
||||
queue_id: Optional[str] = None
|
||||
|
||||
@field_validator("last_applied_at", mode="before")
|
||||
@classmethod
|
||||
def _coerce_dt(cls, v):
|
||||
return _dt(v)
|
||||
|
||||
|
||||
class RecordCreate(BaseModel):
|
||||
name: str = Field(..., example="@")
|
||||
type: str = Field(..., example="A")
|
||||
ttl: Optional[int] = Field(None, ge=0, le=2147483647)
|
||||
value: str = Field(..., example="192.168.1.1")
|
||||
priority: Optional[int] = Field(None, ge=0, le=65535)
|
||||
active: bool = Field(True)
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
v = v.strip().upper()
|
||||
if v not in VALID_RECORD_TYPES:
|
||||
raise ValueError(f"Unsupported type '{v}'")
|
||||
return v
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return "@"
|
||||
if not RE_DNS_NAME.match(v):
|
||||
raise ValueError(f"Invalid DNS name '{v}'")
|
||||
return v
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_value(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Record value cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_by_type(self) -> "RecordCreate":
|
||||
t, v = self.type, self.value
|
||||
if t == "A":
|
||||
try: ipaddress.IPv4Address(v)
|
||||
except ValueError: raise ValueError(f"Invalid IPv4: '{v}'")
|
||||
elif t == "AAAA":
|
||||
try: ipaddress.IPv6Address(v)
|
||||
except ValueError: raise ValueError(f"Invalid IPv6: '{v}'")
|
||||
elif t in ("CNAME", "NS", "PTR"):
|
||||
if not RE_DNS_NAME.match(v.rstrip(".")):
|
||||
raise ValueError(f"Invalid hostname for {t}: '{v}'")
|
||||
elif t == "MX":
|
||||
if self.priority is None:
|
||||
raise ValueError("MX requires a priority")
|
||||
if not RE_DNS_NAME.match(v.rstrip(".")):
|
||||
raise ValueError(f"Invalid MX hostname: '{v}'")
|
||||
elif t == "SRV":
|
||||
if self.priority is None:
|
||||
raise ValueError("SRV requires a priority")
|
||||
parts = v.split()
|
||||
if len(parts) != 3:
|
||||
raise ValueError("SRV: '<weight> <port> <target>'")
|
||||
try:
|
||||
w, p = int(parts[0]), int(parts[1])
|
||||
assert 0 <= w <= 65535 and 0 <= p <= 65535
|
||||
except (ValueError, AssertionError):
|
||||
raise ValueError("SRV weight/port must be 0-65535")
|
||||
elif t == "TXT":
|
||||
if "\n" in v or "\r" in v:
|
||||
raise ValueError("TXT must not contain newlines")
|
||||
elif t == "TLSA":
|
||||
parts = v.split(None, 3)
|
||||
if len(parts) != 4:
|
||||
raise ValueError("TLSA: '<usage> <selector> <matching-type> <cert-hex>'")
|
||||
try:
|
||||
usage, sel, mt = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
except ValueError:
|
||||
raise ValueError("TLSA usage/selector/matching-type must be integers")
|
||||
if usage not in range(4): raise ValueError("TLSA usage 0-3")
|
||||
if sel not in (0, 1): raise ValueError("TLSA selector 0 or 1")
|
||||
if mt not in (0, 1, 2): raise ValueError("TLSA matching-type 0-2")
|
||||
if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")):
|
||||
raise ValueError("TLSA cert data must be hex")
|
||||
elif t == "DS":
|
||||
parts = v.split(None, 3)
|
||||
if len(parts) != 4:
|
||||
raise ValueError("DS: '<keytag> <algo> <digest-type> <digest-hex>'")
|
||||
try:
|
||||
kt, algo, dt = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
except ValueError:
|
||||
raise ValueError("DS keytag/algo/digest-type must be integers")
|
||||
if not (0 <= kt <= 65535): raise ValueError("DS keytag 0-65535")
|
||||
if algo not in {5,7,8,10,13,14,15,16}: raise ValueError(f"DS unknown algorithm {algo}")
|
||||
if dt not in (1, 2, 4): raise ValueError("DS digest-type: 1, 2 or 4")
|
||||
if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")):
|
||||
raise ValueError("DS digest must be hex")
|
||||
elif t == "CAA":
|
||||
parts = v.split(None, 2)
|
||||
if len(parts) != 3: raise ValueError("CAA: '<flags> <tag> <value>'")
|
||||
try:
|
||||
flags = int(parts[0])
|
||||
assert 0 <= flags <= 255
|
||||
except (ValueError, AssertionError):
|
||||
raise ValueError("CAA flags 0-255")
|
||||
if parts[1] not in ("issue", "issuewild", "iodef"):
|
||||
raise ValueError("CAA tag: issue, issuewild or iodef")
|
||||
return self
|
||||
|
||||
|
||||
class RecordResponse(BaseModel):
|
||||
id: str
|
||||
zone: str
|
||||
name: str
|
||||
type: str
|
||||
ttl: Optional[int]
|
||||
value: str
|
||||
priority: Optional[int] = None
|
||||
active: bool = True
|
||||
queued: bool = False
|
||||
queue_id: Optional[str] = None
|
||||
pending_sync: bool = False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _zone_to_payload(zone_name: str) -> dict:
|
||||
"""Build the full zone payload to send to the DNS agent."""
|
||||
with get_db() as conn:
|
||||
z = conn.execute("SELECT * FROM zones WHERE name=%s", (zone_name,)).fetchone()
|
||||
if not z:
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
records = conn.execute(
|
||||
"SELECT * FROM records WHERE zone=%s ORDER BY type, name", (zone_name,)
|
||||
).fetchall()
|
||||
return {
|
||||
"zone": z["name"],
|
||||
"admin": z["admin"],
|
||||
"ttl": z["ttl"],
|
||||
"refresh": z["refresh"],
|
||||
"retry": z["retry"],
|
||||
"expire": z["expire"],
|
||||
"negative_ttl": z["negative_ttl"],
|
||||
"records": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"type": r["type"],
|
||||
"ttl": r["ttl"],
|
||||
"value": r["value"],
|
||||
"priority": r["priority"],
|
||||
"active": bool(r["active"]), # inactive → commented in zone file
|
||||
}
|
||||
for r in records
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _apply_zone(zone_name: str) -> dict:
|
||||
"""Send zone to DNS agent. If unreachable, queue for later.
|
||||
Returns {"queued": bool, "queue_id": str|None}.
|
||||
Never raises — save always succeeds in SQLite.
|
||||
"""
|
||||
payload = _zone_to_payload(zone_name)
|
||||
result = publish_or_queue("dns", "apply_zone", payload)
|
||||
queued = result.get("queued", False)
|
||||
now = datetime.datetime.utcnow().isoformat()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE zones SET last_applied_at=%s, last_apply_ok=%s WHERE name=%s",
|
||||
(now, 0 if queued else 1, zone_name)
|
||||
)
|
||||
if not queued:
|
||||
# Agent confirmed — clear pending_sync on all records of this zone
|
||||
conn.execute(
|
||||
"UPDATE records SET pending_sync=0 WHERE zone=%s", (zone_name,)
|
||||
)
|
||||
return {"queued": queued, "queue_id": result.get("id")}
|
||||
|
||||
|
||||
def _delete_zone_on_agent(zone_name: str) -> dict:
|
||||
"""Delete zone on agent. If unreachable, queue for later."""
|
||||
result = publish_or_queue("dns", "delete_zone", {"zone": zone_name})
|
||||
return {"queued": result.get("queued", False), "queue_id": result.get("id")}
|
||||
|
||||
|
||||
def _row_to_zone(row, record_count: int = 0) -> ZoneInfo:
|
||||
return ZoneInfo(
|
||||
name=row["name"], admin=row["admin"], ttl=row["ttl"],
|
||||
refresh=row["refresh"], retry=row["retry"],
|
||||
expire=row["expire"], negative_ttl=row["negative_ttl"],
|
||||
record_count=record_count,
|
||||
last_apply_ok=bool(row["last_apply_ok"]) if row["last_apply_ok"] is not None else None,
|
||||
last_applied_at=_dt(row["last_applied_at"]),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Routes — Zones
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@router.get("/zones", response_model=List[ZoneInfo])
|
||||
def list_zones(user: dict = Depends(get_current_user)):
|
||||
allowed = get_allowed_dns_domains(user)
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("SELECT * FROM zones ORDER BY name").fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
if allowed is not None and row["name"] not in allowed:
|
||||
continue
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM records WHERE zone=%s", (row["name"],)
|
||||
).fetchone()["n"]
|
||||
result.append(_row_to_zone(row, count))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/zones", response_model=ZoneInfo, status_code=201)
|
||||
def create_zone(body: ZoneCreate, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(body.name, user)
|
||||
with get_db() as conn:
|
||||
existing = conn.execute("SELECT name FROM zones WHERE name=%s", (body.name,)).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(409, f"Zone '{body.name}' already exists")
|
||||
conn.execute(
|
||||
"INSERT INTO zones(name,admin,ttl,refresh,retry,expire,negative_ttl) VALUES(%s,%s,%s,%s,%s,%s,%s)",
|
||||
(body.name, body.admin, body.ttl, body.refresh, body.retry, body.expire, body.negative_ttl)
|
||||
)
|
||||
q = _apply_zone(body.name)
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone()
|
||||
audit.record("dns", "create_zone", target=f"zone:{body.name}",
|
||||
detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]},
|
||||
user=user, ip=get_request_ip())
|
||||
z = _row_to_zone(row, 0)
|
||||
z.queued = q["queued"]
|
||||
z.queue_id = q.get("queue_id")
|
||||
return z
|
||||
|
||||
|
||||
@router.delete("/zones/{zone_name}", status_code=204)
|
||||
def delete_zone(zone_name: str, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
conn.execute("DELETE FROM zones WHERE name=%s", (zone_name,))
|
||||
q = _delete_zone_on_agent(zone_name)
|
||||
audit.record("dns", "delete_zone", target=f"zone:{zone_name}",
|
||||
detail={"queued": q["queued"]}, user=user, ip=get_request_ip())
|
||||
|
||||
|
||||
@router.get("/zones/{zone_name}/config")
|
||||
def get_zone_config(zone_name: str, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT * FROM zones WHERE name=%s", (zone_name,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
return {
|
||||
"zone": zone_name, "admin": row["admin"], "ttl": row["ttl"],
|
||||
"refresh": row["refresh"], "retry": row["retry"],
|
||||
"expire": row["expire"], "negative_ttl": row["negative_ttl"],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/zones/{zone_name}/config")
|
||||
def update_zone_config(zone_name: str, body: ZoneConfig, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
conn.execute(
|
||||
"UPDATE zones SET admin=%s,ttl=%s,refresh=%s,retry=%s,expire=%s,negative_ttl=%s WHERE name=%s",
|
||||
(body.admin, body.ttl, body.refresh, body.retry, body.expire, body.negative_ttl, zone_name)
|
||||
)
|
||||
q = _apply_zone(zone_name)
|
||||
audit.record("dns", "update_zone_config", target=f"zone:{zone_name}",
|
||||
detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]},
|
||||
user=user, ip=get_request_ip())
|
||||
cfg_data = get_zone_config(zone_name, user)
|
||||
cfg_data["queued"] = q["queued"]
|
||||
cfg_data["queue_id"] = q.get("queue_id")
|
||||
return cfg_data
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Routes — Records
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/zones/{zone_name}/records", response_model=List[RecordResponse])
|
||||
def list_records(zone_name: str, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM records WHERE zone=%s ORDER BY type, name", (zone_name,)
|
||||
).fetchall()
|
||||
return [RecordResponse(
|
||||
id=r["id"], zone=r["zone"], name=r["name"], type=r["type"],
|
||||
ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]),
|
||||
pending_sync=bool(r["pending_sync"]) if r["pending_sync"] is not None else False
|
||||
) for r in rows]
|
||||
|
||||
|
||||
@router.post("/zones/{zone_name}/records", response_model=RecordResponse, status_code=201)
|
||||
def create_record(zone_name: str, body: RecordCreate, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
rec_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
"INSERT INTO records(id,zone,name,type,ttl,value,priority,active,pending_sync) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,1)",
|
||||
(rec_id, zone_name, body.name or "@", body.type, body.ttl,
|
||||
body.value, body.priority, int(body.active))
|
||||
)
|
||||
q = _apply_zone(zone_name)
|
||||
with get_db() as conn:
|
||||
r = conn.execute("SELECT * FROM records WHERE id=%s", (rec_id,)).fetchone()
|
||||
audit.record("dns", "create_record", target=f"zone:{zone_name}",
|
||||
detail={"name": body.name, "type": body.type, "value": body.value, "queued": q["queued"]},
|
||||
user=user, ip=get_request_ip())
|
||||
return RecordResponse(
|
||||
id=r["id"], zone=r["zone"], name=r["name"], type=r["type"],
|
||||
ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]),
|
||||
queued=q["queued"], queue_id=q.get("queue_id"),
|
||||
pending_sync=q["queued"] # still pending if queued
|
||||
)
|
||||
|
||||
|
||||
@router.put("/zones/{zone_name}/records/{record_id}", response_model=RecordResponse)
|
||||
def update_record(zone_name: str, record_id: str, body: RecordCreate,
|
||||
user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT id FROM records WHERE id=%s AND zone=%s", (record_id, zone_name)).fetchone():
|
||||
raise HTTPException(404, "Record not found")
|
||||
conn.execute(
|
||||
"UPDATE records SET name=%s,type=%s,ttl=%s,value=%s,priority=%s,active=%s,pending_sync=1 WHERE id=%s",
|
||||
(body.name or "@", body.type, body.ttl, body.value, body.priority,
|
||||
int(body.active), record_id)
|
||||
)
|
||||
q = _apply_zone(zone_name)
|
||||
with get_db() as conn:
|
||||
r = conn.execute("SELECT * FROM records WHERE id=%s", (record_id,)).fetchone()
|
||||
audit.record("dns", "update_record", target=f"zone:{zone_name}/record:{record_id}",
|
||||
detail={"name": body.name, "type": body.type, "value": body.value, "queued": q["queued"]},
|
||||
user=user, ip=get_request_ip())
|
||||
return RecordResponse(
|
||||
id=r["id"], zone=r["zone"], name=r["name"], type=r["type"],
|
||||
ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]),
|
||||
queued=q["queued"], queue_id=q.get("queue_id"),
|
||||
pending_sync=q["queued"]
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/zones/{zone_name}/records/{record_id}")
|
||||
def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_current_user)):
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT id FROM records WHERE id=%s AND zone=%s", (record_id, zone_name)).fetchone():
|
||||
raise HTTPException(404, "Record not found")
|
||||
conn.execute("DELETE FROM records WHERE id=%s", (record_id,))
|
||||
q = _apply_zone(zone_name)
|
||||
audit.record("dns", "delete_record", target=f"zone:{zone_name}/record:{record_id}",
|
||||
detail={"queued": q["queued"]}, user=user, ip=get_request_ip())
|
||||
return {"queued": q["queued"], "queue_id": q.get("queue_id")}
|
||||
|
||||
|
||||
@router.post("/zones/{zone_name}/reload")
|
||||
def reload_zone(zone_name: str, user: dict = Depends(get_current_user)):
|
||||
"""Force rndc reload on the zone — does not modify the zone file."""
|
||||
require_dns_access(zone_name, user)
|
||||
with get_db() as conn:
|
||||
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
|
||||
raise HTTPException(404, f"Zone '{zone_name}' not found")
|
||||
result = publish_or_queue("dns", "reload_zone", {"zone": zone_name})
|
||||
audit.record("dns", "reload_zone", target=f"zone:{zone_name}",
|
||||
detail={"queued": result.get("queued")},
|
||||
user=user, ip=get_request_ip())
|
||||
return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("id")}
|
||||
|
||||
|
||||
# ── Raw zone (read-only — agent generates it) ──────────────────────
|
||||
|
||||
@router.get("/zones/{zone_name}/raw")
|
||||
def get_zone_raw(zone_name: str, user: dict = Depends(get_current_user)):
|
||||
"""Ask the DNS agent to return the current raw zone file content."""
|
||||
require_dns_access(zone_name, user)
|
||||
try:
|
||||
ack = publish_dns("get_zone_raw", {"zone": zone_name})
|
||||
if not ack.get("success"):
|
||||
raise HTTPException(500, f"Agent error: {ack.get('error','')}")
|
||||
return {"zone": zone_name, "content": ack.get("content", ""), "path": ack.get("path", "")}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(503, str(e))
|
||||
|
||||
|
||||
@router.put("/zones/{zone_name}/raw")
|
||||
def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_user)):
|
||||
"""Send raw zone content to the agent for direct write (advanced use)."""
|
||||
require_dns_access(zone_name, user)
|
||||
content = body.get("content", "")
|
||||
if not content.strip():
|
||||
raise HTTPException(422, "Zone content cannot be empty")
|
||||
try:
|
||||
ack = publish_dns("save_zone_raw", {"zone": zone_name, "content": content})
|
||||
if not ack.get("success"):
|
||||
raise HTTPException(500, f"Agent error: {ack.get('error','')}")
|
||||
return {"zone": zone_name, "content": ack.get("content", content)}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(503, str(e))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Sync from BIND agent (zone files are source of truth)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") -> dict:
|
||||
"""Pull zone state from BIND via the DNS agent and update local SQLite.
|
||||
|
||||
Zone files on the BIND server have priority over SQLite.
|
||||
- Zone exists on BIND but not in SQLite → imported
|
||||
- Zone exists in SQLite but not on BIND → removed from SQLite
|
||||
- Records are fully replaced per zone (zone file is authoritative)
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
payload = {"zone": zone_filter} if zone_filter else {}
|
||||
ack = publish_dns("get_state", payload)
|
||||
except RuntimeError as e:
|
||||
audit.record_sync("dns", trigger, duration_ms=int((time.monotonic()-t0)*1000),
|
||||
success=False, error=str(e))
|
||||
raise HTTPException(503, f"DNS agent unreachable: {e}")
|
||||
|
||||
if not ack.get("success"):
|
||||
err = ack.get("error", "")
|
||||
audit.record_sync("dns", trigger, duration_ms=int((time.monotonic()-t0)*1000),
|
||||
success=False, error=err)
|
||||
raise HTTPException(500, f"DNS agent error: {err}")
|
||||
|
||||
imported_zones = 0
|
||||
updated_zones = 0
|
||||
imported_records = 0
|
||||
|
||||
remote_zone_names = {z["name"] for z in ack.get("zones", [])}
|
||||
|
||||
with get_db() as conn:
|
||||
# If full sync (no filter): remove zones that no longer exist on BIND
|
||||
if not zone_filter:
|
||||
local_zones = {
|
||||
r["name"] for r in conn.execute("SELECT name FROM zones").fetchall()
|
||||
}
|
||||
for stale in local_zones - remote_zone_names:
|
||||
conn.execute("DELETE FROM records WHERE zone=%s", (stale,))
|
||||
conn.execute("DELETE FROM zones WHERE name=%s", (stale,))
|
||||
|
||||
for zone in ack.get("zones", []):
|
||||
name = zone["name"]
|
||||
admin = zone.get("admin", "hostmaster")
|
||||
ttl = zone.get("ttl", 3600)
|
||||
refresh = zone.get("refresh", 43200)
|
||||
retry = zone.get("retry", 3600)
|
||||
expire = zone.get("expire", 2419200)
|
||||
negative_ttl = zone.get("negative_ttl", 3600)
|
||||
records = zone.get("records", [])
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT name FROM zones WHERE name=%s", (name,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE zones
|
||||
SET admin=%s, ttl=%s, refresh=%s, retry=%s, expire=%s, negative_ttl=%s,
|
||||
last_applied_at=NOW(), last_apply_ok=1
|
||||
WHERE name=%s""",
|
||||
(admin, ttl, refresh, retry, expire, negative_ttl, name)
|
||||
)
|
||||
updated_zones += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO zones
|
||||
(name, admin, ttl, refresh, retry, expire, negative_ttl,
|
||||
last_applied_at, last_apply_ok)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,NOW(),1)""",
|
||||
(name, admin, ttl, refresh, retry, expire, negative_ttl)
|
||||
)
|
||||
imported_zones += 1
|
||||
|
||||
# Sync records using stable deterministic IDs based on content.
|
||||
# This prevents IDs from changing on every sync poll, which would
|
||||
# break in-flight frontend requests (PUT/DELETE on stale IDs).
|
||||
#
|
||||
# Stable ID = sha1(zone:name:type:value) — same record always gets
|
||||
# the same UUID-like hex string regardless of sync timing.
|
||||
|
||||
def _stable_id(zone_name: str, rname: str, rtype: str, rvalue: str) -> str:
|
||||
key = f"{zone_name}:{rname}:{rtype}:{rvalue}".encode()
|
||||
h = hashlib.sha1(key).hexdigest()
|
||||
# Format as UUID (8-4-4-4-12)
|
||||
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
|
||||
|
||||
incoming_ids = set()
|
||||
for rec in records:
|
||||
rname = rec.get("name", "@") or "@"
|
||||
rtype = rec["type"]
|
||||
rvalue = rec["value"]
|
||||
rec_id = _stable_id(name, rname, rtype, rvalue)
|
||||
incoming_ids.add(rec_id)
|
||||
|
||||
existing_rec = conn.execute(
|
||||
"SELECT id FROM records WHERE id=%s", (rec_id,)
|
||||
).fetchone()
|
||||
|
||||
if existing_rec:
|
||||
# Update mutable fields — keep same ID, clear pending_sync
|
||||
conn.execute(
|
||||
"""UPDATE records
|
||||
SET ttl=%s, priority=%s, active=COALESCE(active,1), pending_sync=0
|
||||
WHERE id=%s""",
|
||||
(rec.get("ttl"), rec.get("priority"), rec_id)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO records
|
||||
(id, zone, name, type, ttl, value, priority, active, pending_sync)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,1,0)""",
|
||||
(rec_id, name, rname, rtype,
|
||||
rec.get("ttl"), rvalue, rec.get("priority"))
|
||||
)
|
||||
imported_records += 1
|
||||
|
||||
# Remove records that no longer exist in the zone file
|
||||
existing_ids = {
|
||||
r["id"] for r in conn.execute(
|
||||
"SELECT id FROM records WHERE zone=%s", (name,)
|
||||
).fetchall()
|
||||
}
|
||||
for stale_id in existing_ids - incoming_ids:
|
||||
conn.execute("DELETE FROM records WHERE id=%s", (stale_id,))
|
||||
|
||||
result = {
|
||||
"imported_zones": imported_zones,
|
||||
"updated_zones": updated_zones,
|
||||
"imported_records": imported_records,
|
||||
"total_zones": len(ack.get("zones", [])),
|
||||
}
|
||||
audit.record_sync(
|
||||
"dns", trigger,
|
||||
duration_ms=int((time.monotonic()-t0)*1000),
|
||||
zones=result["total_zones"],
|
||||
records=result["imported_records"],
|
||||
success=True,
|
||||
detail=result,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/sync/history")
|
||||
def get_sync_history(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Return DNS sync history (global admin only)."""
|
||||
from routers.auth import _has_role
|
||||
if not _has_role(user, "global_admin"):
|
||||
raise HTTPException(403, "Accès réservé à l'admin global")
|
||||
return {
|
||||
"total": audit.count_sync_history("dns"),
|
||||
"entries": audit.query_sync_history("dns", limit=limit, offset=offset),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
def sync_endpoint(
|
||||
zone: str | None = None,
|
||||
user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Pull current zone state from the BIND agent and update local SQLite.
|
||||
BIND zone files are the source of truth.
|
||||
Optional query param: %szone=example.com to sync a single zone.
|
||||
"""
|
||||
if zone:
|
||||
require_dns_access(zone, user)
|
||||
result = sync_from_agent(zone, trigger="manual")
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": (
|
||||
f"Sync DNS terminé : {result['total_zones']} zone(s) traitée(s), "
|
||||
f"{result['imported_zones']} importée(s), "
|
||||
f"{result['updated_zones']} mise(s) à jour, "
|
||||
f"{result['imported_records']} enregistrement(s)."
|
||||
),
|
||||
"details": result,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetAdmin — DNS & Mail Console</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "dns-mail-manager",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Syne:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg-0: #050708;
|
||||
--bg-1: #0b0e11;
|
||||
--bg-2: #111519;
|
||||
--bg-3: #181d22;
|
||||
--bg-4: #1e252c;
|
||||
--border: #1f2a33;
|
||||
--border-hi: #2a3a47;
|
||||
--text-0: #e8edf2;
|
||||
--text-1: #a8b8c8;
|
||||
--text-2: #5a7080;
|
||||
--text-3: #344550;
|
||||
--accent: #00d4ff;
|
||||
--accent-dim: rgba(0, 212, 255, 0.12);
|
||||
--accent-glow: rgba(0, 212, 255, 0.25);
|
||||
--green: #00e5a0;
|
||||
--green-dim: rgba(0, 229, 160, 0.12);
|
||||
--red: #ff4a6a;
|
||||
--red-dim: rgba(255, 74, 106, 0.12);
|
||||
--yellow: #ffb700;
|
||||
--yellow-dim: rgba(255, 183, 0, 0.12);
|
||||
--orange: #ff7d40;
|
||||
--sidebar-w: 240px;
|
||||
--radius: 6px;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-display: 'Syne', sans-serif;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--bg-0);
|
||||
color: var(--text-0);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── APP LAYOUT ── */
|
||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── SIDEBAR ── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
min-width: var(--sidebar-w);
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: -1px; bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, transparent, var(--accent), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 24px;
|
||||
color: var(--accent);
|
||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--text-0);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-2);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-nav { flex: 1; padding: 0 10px; display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--bg-3); color: var(--text-1); }
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(0,212,255,0.15);
|
||||
}
|
||||
|
||||
.nav-icon { font-size: 14px; width: 18px; text-align: center; }
|
||||
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background: var(--accent);
|
||||
border-radius: 3px 0 0 3px;
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-label { font-size: 9px; letter-spacing: 0.15em; color: var(--text-3); text-transform: uppercase; display: block; margin-bottom: 4px; }
|
||||
|
||||
.api-input {
|
||||
width: 100%;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.api-input:focus { border-color: var(--accent); }
|
||||
|
||||
.status-dot { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-2); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.dot--green { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2s infinite; }
|
||||
.dot--red { background: var(--red); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ── MAIN AREA ── */
|
||||
.main { flex: 1; overflow-y: auto; background: var(--bg-0); }
|
||||
.main-inner { padding: 32px; max-width: 1200px; }
|
||||
|
||||
/* ── PAGE HEADER ── */
|
||||
.page-header { margin-bottom: 28px; }
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.page-title .icon { color: var(--accent); }
|
||||
.page-sub { color: var(--text-2); font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* ── CARDS ── */
|
||||
.card {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
/* ── TOOLBAR ── */
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 12px;
|
||||
color: var(--text-0);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-box:focus { border-color: var(--accent); }
|
||||
.search-box::placeholder { color: var(--text-3); }
|
||||
|
||||
/* ── BUTTONS ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-0);
|
||||
}
|
||||
.btn-primary:hover { background: #33ddff; box-shadow: 0 0 12px var(--accent-glow); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-3);
|
||||
color: var(--text-1);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-4); border-color: var(--border-hi); color: var(--text-0); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red-dim);
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(255,74,106,0.2);
|
||||
}
|
||||
.btn-danger:hover { background: rgba(255,74,106,0.2); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text-0); }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 11px; }
|
||||
|
||||
/* ── TABLE ── */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
thead tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(31,42,51,0.6);
|
||||
color: var(--text-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
tbody tr { transition: background 0.1s; }
|
||||
tbody tr:hover { background: var(--bg-2); }
|
||||
|
||||
.cell-mono { font-family: var(--font-mono); color: var(--text-0); }
|
||||
.cell-dim { color: var(--text-2); }
|
||||
.cell-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
|
||||
/* ── BADGES ── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-a { background: rgba(0,212,255,0.12); color: var(--accent); }
|
||||
.badge-aaaa { background: rgba(0,229,160,0.12); color: var(--green); }
|
||||
.badge-mx { background: rgba(255,183,0,0.12); color: var(--yellow); }
|
||||
.badge-cname { background: rgba(255,125,64,0.12); color: var(--orange); }
|
||||
.badge-txt { background: rgba(130,100,255,0.12); color: #9d7aff; }
|
||||
.badge-ns { background: rgba(255,74,106,0.12); color: var(--red); }
|
||||
.badge-ptr { background: rgba(100,180,255,0.12); color: #64b4ff; }
|
||||
.badge-srv { background: rgba(0,229,160,0.1); color: #00c88a; }
|
||||
.badge-default { background: var(--bg-3); color: var(--text-2); }
|
||||
.badge-active { background: var(--green-dim); color: var(--green); }
|
||||
.badge-inactive { background: var(--red-dim); color: var(--red); }
|
||||
|
||||
/* ── MODAL ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 10px;
|
||||
width: 520px;
|
||||
max-width: 94vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.2s ease;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-3); color: var(--text-0); }
|
||||
|
||||
.modal-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-footer { padding: 16px 22px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* ── FORM ── */
|
||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-2); }
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-0);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: var(--accent); }
|
||||
.form-select { cursor: pointer; }
|
||||
.form-textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
/* ── SELECT ZONE TABS ── */
|
||||
.zone-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.zone-tab {
|
||||
padding: 5px 14px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.zone-tab:hover { border-color: var(--border-hi); color: var(--text-1); }
|
||||
.zone-tab.active { background: var(--accent-dim); border-color: rgba(0,212,255,0.3); color: var(--accent); }
|
||||
|
||||
/* ── EMPTY STATE ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.empty-icon { font-size: 32px; margin-bottom: 10px; }
|
||||
.empty-text { font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* ── ALERT ── */
|
||||
.alert {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
border-left: 3px solid;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.alert-error { background: var(--red-dim); border-color: var(--red); color: var(--red); }
|
||||
.alert-success { background: var(--green-dim); border-color: var(--green); color: var(--green); }
|
||||
|
||||
/* ── QUOTA BAR ── */
|
||||
.quota-bar { display: flex; flex-direction: column; gap: 4px; min-width: 100px; }
|
||||
.quota-track { height: 4px; background: var(--bg-3); border-radius: 2px; overflow: hidden; }
|
||||
.quota-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
|
||||
.quota-fill--low { background: var(--green); }
|
||||
.quota-fill--mid { background: var(--yellow); }
|
||||
.quota-fill--high { background: var(--red); }
|
||||
.quota-text { font-size: 10px; color: var(--text-2); }
|
||||
|
||||
/* ── SCROLLBAR ── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-hi); }
|
||||
|
||||
/* ── LOADING ── */
|
||||
.loading { display: flex; align-items: center; gap: 10px; padding: 20px; color: var(--text-2); font-size: 12px; }
|
||||
.spinner { width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import DomainsManager from "./components/DomainsManager";
|
||||
import DNSManager from "./components/DNSManager";
|
||||
import MailManager from "./components/MailManager";
|
||||
import AuditLog from "./components/AuditLog";
|
||||
import SyncDashboard from "./components/SyncDashboard";
|
||||
import UserProfile from "./components/UserProfile";
|
||||
import MailingLists from "./components/MailingLists";
|
||||
import UsersManager from "./components/UsersManager";
|
||||
import "./App.css";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id:"domains", label:"Domaines", icon:"◇", roles:["global_admin","domain_admin","dns_admin","mail_admin","mail_domain_admin","dns_zone_admin"] },
|
||||
{ id:"dns", label:"DNS", icon:"◈", roles:["global_admin","dns_admin","domain_admin","dns_zone_admin"] },
|
||||
{ id:"mail", label:"Comptes mail", icon:"◉", roles:["global_admin","mail_admin","domain_admin","mail_domain_admin"] },
|
||||
{ id:"lists", label:"Listes diffusion", icon:"◎", roles:["global_admin","mail_admin","domain_admin","mail_domain_admin"] },
|
||||
{ id:"users", label:"Utilisateurs", icon:"⊙", roles:["global_admin"] },
|
||||
{ id:"audit", label:"Journal d'audit", icon:"📋", roles:["global_admin"] },
|
||||
{ id:"sync", label:"Synchronisations", icon:"⟳", roles:["global_admin"] },
|
||||
{ id:"profile", label:"Mon compte", icon:"◎", roles:["global_admin","dns_admin","mail_admin","domain_admin","dns_zone_admin","mail_domain_admin"] },
|
||||
];
|
||||
|
||||
function hasAccess(user, roles) {
|
||||
if (!user) return false;
|
||||
return user.roles?.some(r => r.role === "global_admin" || roles.includes(r.role));
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem("netadmin_token") || null);
|
||||
const [user, setUser] = useState(null);
|
||||
const [active, setActive] = useState("domains");
|
||||
const [apiBase, setApiBase] = useState(() => localStorage.getItem("netadmin_api") || "http://localhost:8000");
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// Persist token
|
||||
useEffect(() => {
|
||||
if (token) localStorage.setItem("netadmin_token", token);
|
||||
else localStorage.removeItem("netadmin_token");
|
||||
}, [token]);
|
||||
|
||||
// Persist apiBase
|
||||
useEffect(() => {
|
||||
localStorage.setItem("netadmin_api", apiBase);
|
||||
}, [apiBase]);
|
||||
|
||||
const doLogout = useCallback(async (callServer = true) => {
|
||||
if (callServer && token) {
|
||||
try {
|
||||
await fetch(`${apiBase}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, [token, apiBase]);
|
||||
|
||||
// Validate token on mount
|
||||
useEffect(() => {
|
||||
if (!token) { setChecking(false); setUser(null); return; }
|
||||
setChecking(true);
|
||||
fetch(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => {
|
||||
if (r.status === 401) { doLogout(false); return null; }
|
||||
if (!r.ok) throw new Error("Erreur serveur");
|
||||
return r.json();
|
||||
})
|
||||
.then(u => { if (u) setUser(u); })
|
||||
.catch(() => doLogout(false))
|
||||
.finally(() => setChecking(false));
|
||||
}, [token, apiBase]); // eslint-disable-line
|
||||
|
||||
const onUnauthorized = useCallback(() => doLogout(false), [doLogout]);
|
||||
|
||||
// Called by LoginPage once login + TOTP (if needed) are fully complete
|
||||
const onLogin = useCallback((token, user) => {
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
setActive("domains");
|
||||
}, []);
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{minHeight:"100vh", background:"var(--bg-0)",
|
||||
display:"flex", alignItems:"center", justifyContent:"center", flexDirection:"column", gap:16}}>
|
||||
<div style={{fontSize:36, color:"var(--accent)"}}>⬡</div>
|
||||
<div className="loading">
|
||||
<div className="spinner" />
|
||||
<span style={{color:"var(--text-2)"}}>Vérification de la session…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token || !user) {
|
||||
return <LoginPage apiBase={apiBase} onLogin={onLogin} />;
|
||||
}
|
||||
|
||||
const visibleNav = NAV_ITEMS.filter(n => hasAccess(user, n.roles));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand">
|
||||
<span className="brand-icon">⬡</span>
|
||||
<div>
|
||||
<div className="brand-name">NetAdmin</div>
|
||||
<div className="brand-sub">DNS & Mail Console</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{visibleNav.map(item => (
|
||||
<button key={item.id}
|
||||
className={`nav-item ${active === item.id ? "active" : ""}`}
|
||||
onClick={() => setActive(item.id)}>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
{active === item.id && <span className="nav-indicator" />}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div style={{padding:"8px 10px", background:"var(--bg-2)", borderRadius:6,
|
||||
border:"1px solid var(--border)", marginBottom:10}}>
|
||||
<div style={{fontSize:12, color:"var(--text-0)", fontWeight:500}}>
|
||||
{user.full_name || user.username}
|
||||
</div>
|
||||
<div style={{fontSize:10, color:"var(--text-2)", marginTop:2}}>
|
||||
{user.roles?.map(r => r.role === "global_admin" ? "Admin global" : r.role).slice(0,2).join(", ")}
|
||||
</div>
|
||||
{user.totp_enabled && (
|
||||
<div style={{fontSize:10, color:"var(--green)", marginTop:3}}>🔐 2FA activée</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => doLogout(true)}
|
||||
style={{width:"100%", justifyContent:"center", marginBottom:6}}>
|
||||
⏻ Déconnexion
|
||||
</button>
|
||||
|
||||
<div style={{fontSize:10, color:"var(--text-3)"}}>
|
||||
<label style={{display:"block", marginBottom:3, textTransform:"uppercase", letterSpacing:"0.06em"}}>
|
||||
Serveur API
|
||||
</label>
|
||||
<input
|
||||
style={{width:"100%", background:"var(--bg-0)", border:"1px solid var(--border)",
|
||||
borderRadius:4, padding:"3px 6px", fontSize:11, color:"var(--text-2)",
|
||||
fontFamily:"var(--font-mono)"}}
|
||||
value={apiBase}
|
||||
onChange={e => setApiBase(e.target.value)}
|
||||
placeholder="http://localhost:8000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<div className="main-inner">
|
||||
{active === "domains" && <DomainsManager apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
|
||||
{active === "dns" && <DNSManager apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
|
||||
{active === "mail" && <MailManager apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
|
||||
{active === "lists" && <MailingLists apiBase={apiBase} token={token} onUnauthorized={onUnauthorized} />}
|
||||
{active === "users" && <UsersManager apiBase={apiBase} token={token} currentUser={user} onUnauthorized={onUnauthorized} />}
|
||||
{active === "audit" && <AuditLog apiBase={apiBase} token={token} onUnauthorized={onUnauthorized} />}
|
||||
{active === "sync" && <SyncDashboard apiBase={apiBase} token={token} onUnauthorized={onUnauthorized} />}
|
||||
{active === "profile" && <UserProfile apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const SERVICE_COLORS = {
|
||||
auth: "var(--accent)",
|
||||
dns: "#9d7aff",
|
||||
mail: "var(--yellow)",
|
||||
system: "var(--text-2)",
|
||||
};
|
||||
|
||||
const ACTION_ICONS = {
|
||||
login: "🔑",
|
||||
logout: "🚪",
|
||||
create_user: "👤",
|
||||
assign_role: "🏷",
|
||||
create_zone: "◈",
|
||||
delete_zone: "✕",
|
||||
update_zone_config: "⚙",
|
||||
create_record: "+",
|
||||
update_record: "✎",
|
||||
delete_record: "✕",
|
||||
sync: "⟳",
|
||||
create_account: "◉",
|
||||
update_account: "✎",
|
||||
delete_account: "✕",
|
||||
create_alias: "⇄",
|
||||
update_alias: "✎",
|
||||
delete_alias: "✕",
|
||||
agent_reconnect: "🔌",
|
||||
};
|
||||
|
||||
function Badge({ service }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
padding: "1px 7px", borderRadius: 3,
|
||||
fontSize: 10, fontWeight: 600,
|
||||
textTransform: "uppercase", letterSpacing: "0.06em",
|
||||
background: (SERVICE_COLORS[service] || "var(--text-2)") + "22",
|
||||
color: SERVICE_COLORS[service] || "var(--text-2)",
|
||||
border: `1px solid ${SERVICE_COLORS[service] || "var(--text-2)"}44`,
|
||||
}}>
|
||||
{service}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ success }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-block", width: 8, height: 8, borderRadius: "50%",
|
||||
background: success ? "var(--green)" : "var(--red)",
|
||||
flexShrink: 0,
|
||||
}} title={success ? "Succès" : "Échec"} />
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditLog({ apiBase, token, onUnauthorized }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
// Filters
|
||||
const [filterService, setFilterService] = useState("");
|
||||
const [filterUsername, setFilterUsername] = useState("");
|
||||
const [filterAction, setFilterAction] = useState("");
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const api = useCallback(async (path) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
if (r.status === 401) { onUnauthorized?.(); throw new Error("Session expirée."); }
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({ detail: r.statusText }));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
const load = useCallback((p = 0) => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ limit: PAGE_SIZE, offset: p * PAGE_SIZE });
|
||||
if (filterService) params.set("service", filterService);
|
||||
if (filterUsername) params.set("username", filterUsername);
|
||||
if (filterAction) params.set("action", filterAction);
|
||||
api(`/audit?${params}`)
|
||||
.then(data => { setEntries(data.entries); setTotal(data.total); setPage(p); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api, filterService, filterUsername, filterAction]);
|
||||
|
||||
useEffect(() => { load(0); }, [load]);
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
const fmtDate = (ts) => {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts + "Z");
|
||||
return d.toLocaleString("fr-FR", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">📋</span> Journal d'audit</div>
|
||||
<div className="page-sub">Historique des actions — qui, quand, quoi</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
⚠ {error}
|
||||
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div className="card-body" style={{padding:"12px 16px"}}>
|
||||
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end"}}>
|
||||
<div className="form-group" style={{marginBottom:0,minWidth:130}}>
|
||||
<label className="form-label" style={{marginBottom:4}}>Service</label>
|
||||
<select className="form-select" value={filterService}
|
||||
onChange={e=>{setFilterService(e.target.value);setPage(0);}}>
|
||||
<option value="">Tous</option>
|
||||
<option value="auth">Auth</option>
|
||||
<option value="dns">DNS</option>
|
||||
<option value="mail">Mail</option>
|
||||
<option value="system">Système</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{marginBottom:0,flex:1,minWidth:140}}>
|
||||
<label className="form-label" style={{marginBottom:4}}>Utilisateur</label>
|
||||
<input className="form-input" placeholder="Rechercher…"
|
||||
value={filterUsername} onChange={e=>{setFilterUsername(e.target.value);setPage(0);}} />
|
||||
</div>
|
||||
<div className="form-group" style={{marginBottom:0,flex:1,minWidth:140}}>
|
||||
<label className="form-label" style={{marginBottom:4}}>Action</label>
|
||||
<input className="form-input" placeholder="ex: create_record"
|
||||
value={filterAction} onChange={e=>{setFilterAction(e.target.value);setPage(0);}} />
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>load(0)}
|
||||
style={{alignSelf:"flex-end",marginBottom:0}}>
|
||||
⟳ Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">
|
||||
{total} entrée{total!==1?"s":""}{" "}
|
||||
{(filterService||filterUsername||filterAction) && <span style={{color:"var(--text-3)",fontWeight:400}}>— filtrées</span>}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div style={{display:"flex",gap:6,alignItems:"center"}}>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page===0} onClick={()=>load(page-1)}>‹ Préc.</button>
|
||||
<span style={{fontSize:12,color:"var(--text-2)"}}>{page+1} / {totalPages}</span>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page>=totalPages-1} onClick={()=>load(page+1)}>Suiv. ›</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body" style={{padding:0}}>
|
||||
{loading ? (
|
||||
<div className="loading" style={{padding:24}}><div className="spinner"/> Chargement…</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state" style={{padding:40}}>
|
||||
<div className="empty-icon">📋</div>
|
||||
<div className="empty-text">Aucune entrée</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width:20}}></th>
|
||||
<th>Date</th>
|
||||
<th>Utilisateur</th>
|
||||
<th>IP</th>
|
||||
<th>Service</th>
|
||||
<th>Action</th>
|
||||
<th>Cible</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => (
|
||||
<>
|
||||
<tr key={e.id}
|
||||
onClick={()=>setExpanded(expanded===e.id?null:e.id)}
|
||||
style={{cursor:e.detail||e.error?"pointer":"default",
|
||||
background:expanded===e.id?"var(--bg-2)":"transparent"}}>
|
||||
<td><StatusDot success={!!e.success}/></td>
|
||||
<td className="cell-dim" style={{whiteSpace:"nowrap",fontSize:11}}>
|
||||
{fmtDate(e.ts)}
|
||||
</td>
|
||||
<td className="cell-mono" style={{fontWeight:e.username==="system"?400:500}}>
|
||||
{e.username || "—"}
|
||||
</td>
|
||||
<td className="cell-dim" style={{fontSize:11,fontFamily:"var(--font-mono)"}}>
|
||||
{e.ip || "—"}
|
||||
</td>
|
||||
<td><Badge service={e.service}/></td>
|
||||
<td>
|
||||
<span style={{display:"inline-flex",alignItems:"center",gap:6}}>
|
||||
<span style={{fontSize:13}}>{ACTION_ICONS[e.action]||"•"}</span>
|
||||
<span style={{fontSize:12,color:"var(--text-1)"}}>{e.action}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-mono" style={{fontSize:11,color:"var(--text-2)",maxWidth:220,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||||
{e.target || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded===e.id && (e.detail||e.error) && (
|
||||
<tr key={e.id+"-detail"} style={{background:"var(--bg-0)"}}>
|
||||
<td/>
|
||||
<td colSpan={6} style={{padding:"8px 14px"}}>
|
||||
{e.error && (
|
||||
<div style={{color:"var(--red)",fontSize:12,marginBottom:6}}>
|
||||
⚠ {e.error}
|
||||
</div>
|
||||
)}
|
||||
{e.detail && (
|
||||
<pre style={{
|
||||
margin:0, fontSize:11, color:"var(--text-2)",
|
||||
fontFamily:"var(--font-mono)", whiteSpace:"pre-wrap",
|
||||
wordBreak:"break-all",
|
||||
}}>
|
||||
{JSON.stringify(JSON.parse(e.detail), null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import SyncToast from "./SyncToast";
|
||||
|
||||
const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"];
|
||||
|
||||
const typeBadge = (t) => {
|
||||
const map = { A:"badge-a", AAAA:"badge-aaaa", MX:"badge-mx", CNAME:"badge-cname", TXT:"badge-txt", NS:"badge-ns", PTR:"badge-ptr", SRV:"badge-srv" };
|
||||
return `badge ${map[t] || "badge-default"}`;
|
||||
};
|
||||
|
||||
const EMPTY_RECORD = { name: "", type: "A", ttl: 3600, value: "", priority: "" };
|
||||
const EMPTY_DS = { keyTag: "", algo: "13", digestType: "2", digest: "" };
|
||||
const EMPTY_TLSA = { usage: "3", selector: "1", matchingType: "1", certData: "" };
|
||||
|
||||
export default function DNSManager({ apiBase, token, onUnauthorized }) {
|
||||
const [zones, setZones] = useState([]);
|
||||
const [activeZone, setActiveZone] = useState(null);
|
||||
const [records, setRecords] = useState([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterType, setFilterType] = useState("ALL");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_RECORD);
|
||||
const [ds, setDs] = useState(EMPTY_DS);
|
||||
const [tlsa, setTlsa] = useState(EMPTY_TLSA);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showZoneConfig, setShowZoneConfig] = useState(false);
|
||||
const [zoneConfig, setZoneConfig] = useState({ ttl:3600, admin:"hostmaster", refresh:43200, retry:3600, expire:2419200, negative_ttl:3600 });
|
||||
const [zoneConfigSaving, setZoneConfigSaving] = useState(false);
|
||||
const [zoneConfigError, setZoneConfigError] = useState(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState(null);
|
||||
const [syncSuccess, setSyncSuccess] = useState(true);
|
||||
const [modalError, setModalError] = useState(null);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [rawContent, setRawContent] = useState("");
|
||||
const [rawSaving, setRawSaving] = useState(false);
|
||||
const [rawLoading, setRawLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmZone, setConfirmZone] = useState(null);
|
||||
const [confirmRec, setConfirmRec] = useState(null);
|
||||
const [confirmToggle, setConfirmToggle] = useState(null); // record pending toggle
|
||||
const [confirmInput, setConfirmInput] = useState("");
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const api = useCallback(async (path, options = {}) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
||||
...options,
|
||||
});
|
||||
if (r.status === 401) {
|
||||
onUnauthorized?.();
|
||||
throw new Error("Session expirée — veuillez vous reconnecter.");
|
||||
}
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({ detail: r.statusText }));
|
||||
throw new Error(err.detail || r.statusText);
|
||||
}
|
||||
// 204 No Content — no body to parse
|
||||
if (r.status === 204) return null;
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api("/dns/zones")
|
||||
.then((data) => {
|
||||
setZones(data);
|
||||
if (data.length > 0) setActiveZone(data[0].name);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeZone) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api(`/dns/zones/${activeZone}/records`)
|
||||
.then(setRecords)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [activeZone, api]);
|
||||
|
||||
const [sortCol, setSortCol] = useState("name");
|
||||
const [sortDir, setSortDir] = useState("asc");
|
||||
|
||||
const toggleSort = (col) => {
|
||||
if (sortCol === col) setSortDir((d) => d === "asc" ? "desc" : "asc");
|
||||
else { setSortCol(col); setSortDir("asc"); }
|
||||
};
|
||||
|
||||
const filtered = records.filter((r) => {
|
||||
const matchType = filterType === "ALL" || r.type === filterType;
|
||||
const matchSearch = !search ||
|
||||
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.value.toLowerCase().includes(search.toLowerCase());
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let av = a[sortCol] ?? "";
|
||||
let bv = b[sortCol] ?? "";
|
||||
// numeric sort for ttl and priority
|
||||
if (sortCol === "ttl" || sortCol === "priority") {
|
||||
av = Number(av) || 0;
|
||||
bv = Number(bv) || 0;
|
||||
return sortDir === "asc" ? av - bv : bv - av;
|
||||
}
|
||||
av = String(av).toLowerCase();
|
||||
bv = String(bv).toLowerCase();
|
||||
if (av < bv) return sortDir === "asc" ? -1 : 1;
|
||||
if (av > bv) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const openNew = () => {
|
||||
setForm(EMPTY_RECORD); setDs(EMPTY_DS); setTlsa(EMPTY_TLSA);
|
||||
setModalError(null); setEditRecord(null); setShowModal(true);
|
||||
};
|
||||
const openEdit = (rec) => {
|
||||
setForm({ ...rec });
|
||||
// Pre-fill structured fields when editing DS / TLSA
|
||||
if (rec.type === "DS") {
|
||||
const p = rec.value.split(/\s+/);
|
||||
setDs({ keyTag: p[0]||"", algo: p[1]||"13", digestType: p[2]||"2", digest: p[3]||"" });
|
||||
} else if (rec.type === "TLSA") {
|
||||
const p = rec.value.split(/\s+/);
|
||||
setTlsa({ usage: p[0]||"3", selector: p[1]||"1", matchingType: p[2]||"1", certData: p[3]||"" });
|
||||
}
|
||||
setModalError(null); setEditRecord(rec); setShowModal(true);
|
||||
};
|
||||
|
||||
const showMutationToast = (result) => {
|
||||
if (result?.queued) {
|
||||
setSyncSuccess(false);
|
||||
setSyncMsg("Modification enregistrée. L'agent BIND est injoignable — elle sera appliquée automatiquement dès sa reconnexion.");
|
||||
} else {
|
||||
setSyncSuccess(true);
|
||||
setSyncMsg("Zone mise à jour et rechargée sur le serveur BIND.");
|
||||
}
|
||||
};
|
||||
|
||||
const saveRecord = async () => {
|
||||
setSaving(true);
|
||||
// Build composite value for DS and TLSA from structured sub-fields
|
||||
let compositeValue = form.value;
|
||||
if (form.type === "DS")
|
||||
compositeValue = `${ds.keyTag} ${ds.algo} ${ds.digestType} ${ds.digest}`.trim();
|
||||
if (form.type === "TLSA")
|
||||
compositeValue = `${tlsa.usage} ${tlsa.selector} ${tlsa.matchingType} ${tlsa.certData}`.trim();
|
||||
const payload = {
|
||||
...form,
|
||||
value: compositeValue,
|
||||
priority: (form.priority === "" || form.priority === undefined) ? null : Number(form.priority),
|
||||
};
|
||||
try {
|
||||
if (editRecord) {
|
||||
await api(`/dns/zones/${activeZone}/records/${editRecord.id}`, {
|
||||
method: "PUT", body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
await api(`/dns/zones/${activeZone}/records`, {
|
||||
method: "POST", body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
const updated = await api(`/dns/zones/${activeZone}/records`);
|
||||
setRecords(updated);
|
||||
setShowModal(false);
|
||||
} catch (e) {
|
||||
setModalError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRaw = async () => {
|
||||
setRawLoading(true);
|
||||
setShowRaw(true);
|
||||
try {
|
||||
const data = await api(`/dns/zones/${activeZone}/raw`);
|
||||
setRawContent(data.content);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setShowRaw(false);
|
||||
} finally {
|
||||
setRawLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRaw = async () => {
|
||||
setRawSaving(true);
|
||||
try {
|
||||
const data = await api(`/dns/zones/${activeZone}/raw`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content: rawContent }),
|
||||
});
|
||||
setRawContent(data.content);
|
||||
// Refresh record list
|
||||
const updated = await api(`/dns/zones/${activeZone}/records`);
|
||||
setRecords(updated);
|
||||
setShowRaw(false);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setRawSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyRaw = () => {
|
||||
navigator.clipboard.writeText(rawContent).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRecord = async (rec) => {
|
||||
setConfirmRec(rec);
|
||||
};
|
||||
|
||||
const doDeleteRecord = async () => {
|
||||
const rec = confirmRec;
|
||||
setConfirmRec(null);
|
||||
try {
|
||||
const res = await api(`/dns/zones/${activeZone}/records/${rec.id}`, { method: "DELETE" });
|
||||
setRecords((prev) => prev.filter((r) => r.id !== rec.id));
|
||||
showMutationToast(res || {queued: false});
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecord = (rec) => {
|
||||
setConfirmToggle(rec);
|
||||
};
|
||||
|
||||
const doToggleRecord = async () => {
|
||||
const rec = confirmToggle;
|
||||
setConfirmToggle(null);
|
||||
try {
|
||||
const res = await api(`/dns/zones/${activeZone}/records/${rec.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: rec.name, type: rec.type, ttl: rec.ttl,
|
||||
value: rec.value, priority: rec.priority, active: !rec.active,
|
||||
}),
|
||||
});
|
||||
setRecords(prev => prev.map(r => r.id === rec.id ? {...r, ...res} : r));
|
||||
showMutationToast(res);
|
||||
} catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const reloadZone = async () => {
|
||||
setReloading(true);
|
||||
try {
|
||||
const res = await api(`/dns/zones/${activeZone}/reload`, { method: "POST" });
|
||||
if (res.queued) {
|
||||
setSyncSuccess(false);
|
||||
setSyncMsg("Rechargement mis en file d'attente — l'agent BIND est injoignable.");
|
||||
} else {
|
||||
setSyncSuccess(true);
|
||||
setSyncMsg(`Zone ${activeZone} rechargée sur le serveur BIND (rndc reload).`);
|
||||
}
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setReloading(false); }
|
||||
};
|
||||
|
||||
const openZoneConfig = async () => {
|
||||
setZoneConfigError(null);
|
||||
try {
|
||||
const cfg = await api(`/dns/zones/${activeZone}/config`);
|
||||
setZoneConfig({
|
||||
ttl: cfg.ttl,
|
||||
admin: cfg.admin,
|
||||
refresh: cfg.refresh ?? 43200,
|
||||
retry: cfg.retry ?? 3600,
|
||||
expire: cfg.expire ?? 2419200,
|
||||
negative_ttl: cfg.negative_ttl ?? 3600,
|
||||
});
|
||||
setShowZoneConfig(true);
|
||||
} catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const saveZoneConfig = async () => {
|
||||
setZoneConfigSaving(true); setZoneConfigError(null);
|
||||
try {
|
||||
await api(`/dns/zones/${activeZone}/config`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
admin: zoneConfig.admin,
|
||||
ttl: zoneConfig.ttl,
|
||||
refresh: zoneConfig.refresh,
|
||||
retry: zoneConfig.retry,
|
||||
expire: zoneConfig.expire,
|
||||
negative_ttl: zoneConfig.negative_ttl,
|
||||
}),
|
||||
});
|
||||
setShowZoneConfig(false);
|
||||
} catch(e) { setZoneConfigError(e.message); }
|
||||
finally { setZoneConfigSaving(false); }
|
||||
};
|
||||
|
||||
const syncFromServer = async () => {
|
||||
setSyncing(true); setSyncMsg(null);
|
||||
try {
|
||||
const r = await api("/dns/sync", { method: "POST" });
|
||||
setSyncSuccess(true);
|
||||
setSyncMsg(r.message);
|
||||
const updated = await api("/dns/zones");
|
||||
setZones(updated);
|
||||
if (updated.length > 0 && !updated.find(z => z.name === activeZone)) {
|
||||
setActiveZone(updated[0].name);
|
||||
}
|
||||
} catch(e) {
|
||||
setSyncSuccess(false);
|
||||
setSyncMsg(e.message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteZone = async (zone) => {
|
||||
// Step 1 — open confirmation modal
|
||||
if (confirmZone !== zone) {
|
||||
setConfirmZone(zone);
|
||||
setConfirmInput("");
|
||||
return;
|
||||
}
|
||||
// Step 2 — user must have typed the zone name exactly
|
||||
if (confirmInput.trim() !== zone) return;
|
||||
setConfirmZone(null);
|
||||
setConfirmInput("");
|
||||
try {
|
||||
await api(`/dns/zones/${zone}`, { method: "DELETE" });
|
||||
const updated = await api("/dns/zones");
|
||||
setZones(updated);
|
||||
setActiveZone(updated[0]?.name || null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">◈</span> DNS Zones</div>
|
||||
<div className="page-sub">Manage BIND9 zones and resource records</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">⚠ {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={() => setError(null)}>✕</button></div>}
|
||||
<SyncToast msg={syncMsg} success={syncSuccess} onClose={() => setSyncMsg(null)} />
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title" style={{display:"flex",alignItems:"center",gap:8}}>
|
||||
Zones
|
||||
{pendingCount > 0 && (
|
||||
<span style={{
|
||||
display:"inline-flex",alignItems:"center",gap:4,
|
||||
padding:"2px 8px",borderRadius:3,fontSize:10,fontWeight:600,
|
||||
background:"rgba(255,200,0,0.15)",color:"var(--yellow)",
|
||||
border:"1px solid rgba(255,200,0,0.3)",
|
||||
}} title={`${pendingCount} action(s) en attente de synchronisation`}>
|
||||
⏳ {pendingCount} en attente
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={syncFromServer} disabled={syncing}
|
||||
title="Synchroniser depuis les fichiers de zone BIND">
|
||||
{syncing
|
||||
? <><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> Sync…</>
|
||||
: "⟳ Sync BIND"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{loading && !zones.length ? (
|
||||
<div className="loading"><div className="spinner" /> Chargement des zones…</div>
|
||||
) : (
|
||||
<div style={{display:"flex",alignItems:"center",gap:8}}>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{flex:1, maxWidth:420}}
|
||||
value={activeZone || ""}
|
||||
onChange={(e) => setActiveZone(e.target.value)}
|
||||
disabled={zones.length === 0}
|
||||
>
|
||||
{zones.length === 0
|
||||
? <option value="">— Aucune zone —</option>
|
||||
: zones.map((z) => (
|
||||
<option key={z.name} value={z.name}>
|
||||
{z.last_apply_ok === false ? "⏳ " : ""}{z.name} ({z.record_count} enregistrement{z.record_count !== 1 ? "s" : ""})
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeZone && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">
|
||||
<span style={{color:"var(--accent)"}}>◈</span> {activeZone}
|
||||
<span style={{color:"var(--text-3)",fontSize:11,fontWeight:400}}>— {records.length} record{records.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div style={{display:"flex",gap:6}}>
|
||||
<button className="btn btn-ghost btn-sm" onClick={openZoneConfig}>⚙ Configuration</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={reloadZone} disabled={reloading}
|
||||
title="Forcer rndc reload sur cette zone">
|
||||
{reloading
|
||||
? <><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> …</>
|
||||
: "↺ Recharger"}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={openRaw}>⌨ Raw Zone</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={openNew}>+ Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="toolbar">
|
||||
<input className="search-box" placeholder="Filtrer par nom ou valeur…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<select className="form-select" style={{width:"auto"}} value={filterType} onChange={(e) => setFilterType(e.target.value)}>
|
||||
<option value="ALL">All types</option>
|
||||
{RECORD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner" /> Chargement des enregistrements…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">◈</div>
|
||||
<div className="empty-text">No records found</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
{ col:"name", label:"Nom" },
|
||||
{ col:"type", label:"Type" },
|
||||
{ col:"ttl", label:"TTL" },
|
||||
{ col:"priority", label:"Priorité" },
|
||||
{ col:"value", label:"Valeur" },
|
||||
].map(({ col, label }) => (
|
||||
<th key={col}
|
||||
onClick={() => toggleSort(col)}
|
||||
style={{cursor:"pointer", userSelect:"none", whiteSpace:"nowrap"}}
|
||||
>
|
||||
<span style={{display:"inline-flex", alignItems:"center", gap:4}}>
|
||||
{label}
|
||||
<span style={{
|
||||
display:"inline-flex", flexDirection:"column",
|
||||
lineHeight:1, fontSize:8, opacity: sortCol===col ? 1 : 0.25,
|
||||
}}>
|
||||
<span style={{color: sortCol===col && sortDir==="asc" ? "var(--accent)" : "inherit"}}>▲</span>
|
||||
<span style={{color: sortCol===col && sortDir==="desc" ? "var(--accent)" : "inherit"}}>▼</span>
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((rec) => (
|
||||
<tr key={rec.id} style={{
|
||||
opacity: rec.active === false ? 0.5 : 1,
|
||||
background: rec.pending_sync ? "rgba(255,200,0,0.04)" : "transparent",
|
||||
}}>
|
||||
<td className="cell-mono" style={{whiteSpace:"nowrap"}}>
|
||||
{rec.name || "@"}
|
||||
{rec.pending_sync && (
|
||||
<span title="En attente de l'agent BIND — modification non encore appliquée sur le serveur"
|
||||
style={{marginLeft:5,fontSize:10,color:"var(--yellow)",cursor:"help"}}>⏳</span>
|
||||
)}
|
||||
{rec.active === false && (
|
||||
<span title="Enregistrement désactivé — commenté dans le fichier de zone"
|
||||
style={{marginLeft:4,fontSize:10,color:"var(--text-3)",cursor:"help"}}>○</span>
|
||||
)}
|
||||
</td>
|
||||
<td><span className={typeBadge(rec.type)}>{rec.type}</span></td>
|
||||
<td className="cell-dim">{rec.ttl ?? "—"}s</td>
|
||||
<td className="cell-dim">{rec.priority ?? "—"}</td>
|
||||
<td className="cell-mono" style={{maxWidth:260,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{rec.value}</td>
|
||||
<td>
|
||||
<div className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => toggleRecord(rec)}
|
||||
title={rec.active === false ? "Activer l'enregistrement" : "Désactiver l'enregistrement"}
|
||||
style={{color: rec.active === false ? "var(--text-3)" : "var(--green)"}}
|
||||
>
|
||||
{rec.active === false ? "○" : "●"}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(rec)} title="Modifier">✎</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={() => deleteRecord(rec)} title="Supprimer">✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{editRecord ? "Modifier l'enregistrement" : "Nouvel enregistrement"}</div>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{modalError && (
|
||||
<div className="alert alert-error" style={{marginBottom:0}}>⚠ {modalError}</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name</label>
|
||||
<input className="form-input" placeholder="@ or subdomain" value={form.name} onChange={(e) => setForm({...form, name: e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Type</label>
|
||||
<select className="form-select" value={form.type} onChange={(e) => setForm({...form, type: e.target.value})}>
|
||||
{RECORD_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">TTL (seconds)</label>
|
||||
<input className="form-input" type="number" value={form.ttl} onChange={(e) => setForm({...form, ttl: +e.target.value})} />
|
||||
</div>
|
||||
{(form.type === "MX" || form.type === "SRV") && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Priority</label>
|
||||
<input className="form-input" type="number" value={form.priority} onChange={(e) => setForm({...form, priority: +e.target.value})} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* ── Standard value field (all types except DS and TLSA) ── */}
|
||||
{form.type !== "DS" && form.type !== "TLSA" && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Value</label>
|
||||
<input className="form-input" placeholder={
|
||||
form.type === "A" ? "192.168.1.1" :
|
||||
form.type === "AAAA" ? "2001:db8::1" :
|
||||
form.type === "MX" ? "mail.example.com." :
|
||||
form.type === "CNAME"? "target.example.com." :
|
||||
form.type === "TXT" ? "v=spf1 include:_spf.example.com ~all (ou clé DKIM complète)" :
|
||||
form.type === "SRV" ? "10 443 sip.example.com. (weight port target)" :
|
||||
form.type === "CAA" ? "0 issue \"letsencrypt.org\"" :
|
||||
form.type === "NS" ? "ns1.example.com." :
|
||||
form.type === "PTR" ? "host.example.com." :
|
||||
"value"
|
||||
} value={form.value} onChange={(e) => setForm({...form, value: e.target.value})} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── DS structured fields ── */}
|
||||
{form.type === "DS" && (
|
||||
<>
|
||||
<div style={{
|
||||
padding:"8px 10px", marginBottom:4,
|
||||
background:"var(--bg-0)", border:"1px solid var(--border)",
|
||||
borderRadius:4, fontSize:11, color:"var(--text-2)"
|
||||
}}>
|
||||
<strong style={{color:"var(--yellow)"}}>DS</strong>
|
||||
{" "}— Delegation Signer, utilisé pour la chaîne de confiance DNSSEC.
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Key Tag</label>
|
||||
<input className="form-input" type="number" min="0" max="65535"
|
||||
placeholder="12345"
|
||||
value={ds.keyTag} onChange={(e) => setDs({...ds, keyTag: e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Algorithme</label>
|
||||
<select className="form-select" value={ds.algo}
|
||||
onChange={(e) => setDs({...ds, algo: e.target.value})}>
|
||||
<option value="5">5 — RSA/SHA-1</option>
|
||||
<option value="7">7 — RSA/SHA-1-NSEC3</option>
|
||||
<option value="8">8 — RSA/SHA-256</option>
|
||||
<option value="10">10 — RSA/SHA-512</option>
|
||||
<option value="13">13 — ECDSA/P-256 (SHA-256)</option>
|
||||
<option value="14">14 — ECDSA/P-384 (SHA-384)</option>
|
||||
<option value="15">15 — Ed25519</option>
|
||||
<option value="16">16 — Ed448</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Type de digest</label>
|
||||
<select className="form-select" value={ds.digestType}
|
||||
onChange={(e) => setDs({...ds, digestType: e.target.value})}>
|
||||
<option value="1">1 — SHA-1 (40 caractères hex)</option>
|
||||
<option value="2">2 — SHA-256 (64 caractères hex)</option>
|
||||
<option value="4">4 — SHA-384 (96 caractères hex)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Digest (hex)</label>
|
||||
<input className="form-input" placeholder={
|
||||
ds.digestType === "1" ? "40 caractères hex (SHA-1)" :
|
||||
ds.digestType === "4" ? "96 caractères hex (SHA-384)" :
|
||||
"64 caractères hex (SHA-256)"
|
||||
}
|
||||
value={ds.digest} onChange={(e) => setDs({...ds, digest: e.target.value.replace(/\s/g,"")})}
|
||||
style={{fontFamily:"var(--font-mono)", letterSpacing:"0.04em"}} />
|
||||
{ds.digest && (
|
||||
<div style={{fontSize:10, color: (() => {
|
||||
const expected = ds.digestType==="1"?40:ds.digestType==="4"?96:64;
|
||||
return ds.digest.length===expected?"var(--green)":"var(--yellow)";
|
||||
})(), marginTop:3}}>
|
||||
{ds.digest.length} / {ds.digestType==="1"?40:ds.digestType==="4"?96:64} caractères
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── TLSA structured fields ── */}
|
||||
{form.type === "TLSA" && (
|
||||
<>
|
||||
<div style={{
|
||||
padding:"8px 10px", marginBottom:4,
|
||||
background:"var(--bg-0)", border:"1px solid var(--border)",
|
||||
borderRadius:4, fontSize:11, color:"var(--text-2)"
|
||||
}}>
|
||||
<strong style={{color:"var(--accent)"}}>TLSA</strong>
|
||||
{" "}— DANE, associe un certificat TLS à un nom DNS.
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Usage</label>
|
||||
<select className="form-select" value={tlsa.usage}
|
||||
onChange={(e) => setTlsa({...tlsa, usage: e.target.value})}>
|
||||
<option value="0">0 — PKIX-TA (CA de confiance)</option>
|
||||
<option value="1">1 — PKIX-EE (cert. end-entity)</option>
|
||||
<option value="2">2 — DANE-TA (ancre de confiance)</option>
|
||||
<option value="3">3 — DANE-EE (cert. end-entity)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Selector</label>
|
||||
<select className="form-select" value={tlsa.selector}
|
||||
onChange={(e) => setTlsa({...tlsa, selector: e.target.value})}>
|
||||
<option value="0">0 — Certificat complet</option>
|
||||
<option value="1">1 — SubjectPublicKeyInfo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Matching Type</label>
|
||||
<select className="form-select" value={tlsa.matchingType}
|
||||
onChange={(e) => setTlsa({...tlsa, matchingType: e.target.value})}>
|
||||
<option value="0">0 — Données brutes (pas de hash)</option>
|
||||
<option value="1">1 — SHA-256 (64 caractères hex)</option>
|
||||
<option value="2">2 — SHA-512 (128 caractères hex)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Données du certificat (hex)</label>
|
||||
<input className="form-input" placeholder={
|
||||
tlsa.matchingType==="0" ? "DER complet en hex" :
|
||||
tlsa.matchingType==="2" ? "128 caractères hex (SHA-512)" :
|
||||
"64 caractères hex (SHA-256)"
|
||||
}
|
||||
value={tlsa.certData}
|
||||
onChange={(e) => setTlsa({...tlsa, certData: e.target.value.replace(/\s/g,"")})}
|
||||
style={{fontFamily:"var(--font-mono)", letterSpacing:"0.04em"}} />
|
||||
{tlsa.certData && tlsa.matchingType !== "0" && (
|
||||
<div style={{fontSize:10, color: (() => {
|
||||
const expected = tlsa.matchingType==="2"?128:64;
|
||||
return tlsa.certData.length===expected?"var(--green)":"var(--yellow)";
|
||||
})(), marginTop:3}}>
|
||||
{tlsa.certData.length} / {tlsa.matchingType==="2"?128:64} caractères
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={saveRecord} disabled={saving}>
|
||||
{saving ? "Sauvegarde…" : editRecord ? "Enregistrer" : "Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Zone Modal */}
|
||||
{showRaw && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowRaw(false)}>
|
||||
<div className="modal" style={{width:720, maxWidth:"96vw"}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<span style={{color:"var(--accent)"}}>⌨</span> Zone brute — {activeZone}
|
||||
</div>
|
||||
<button className="modal-close" onClick={() => setShowRaw(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{padding:0}}>
|
||||
{rawLoading ? (
|
||||
<div className="loading" style={{padding:32}}><div className="spinner"/> Chargement…</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={rawContent}
|
||||
onChange={(e) => setRawContent(e.target.value)}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width:"100%",
|
||||
minHeight:420,
|
||||
background:"var(--bg-0)",
|
||||
color:"var(--green)",
|
||||
fontFamily:"var(--font-mono)",
|
||||
fontSize:12,
|
||||
lineHeight:1.7,
|
||||
border:"none",
|
||||
outline:"none",
|
||||
padding:"16px 20px",
|
||||
resize:"vertical",
|
||||
borderRadius:"0 0 0 0",
|
||||
tabSize:8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer" style={{justifyContent:"space-between"}}>
|
||||
<div style={{display:"flex",gap:6,alignItems:"center"}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={copyRaw}>
|
||||
{copied ? "✓ Copié !" : "⎘ Copier"}
|
||||
</button>
|
||||
<span style={{fontSize:10,color:"var(--text-3)"}}>
|
||||
{rawContent.split("\n").length} lignes · {rawContent.length} octets
|
||||
</span>
|
||||
</div>
|
||||
<div style={{display:"flex",gap:6}}>
|
||||
<button className="btn btn-secondary" onClick={() => setShowRaw(false)}>Fermer</button>
|
||||
<button className="btn btn-primary" onClick={saveRaw} disabled={rawSaving || rawLoading}>
|
||||
{rawSaving ? "Sauvegarde…" : "💾 Sauvegarder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Zone Confirmation Modal */}
|
||||
{confirmZone && (
|
||||
<div className="modal-overlay" onClick={() => { setConfirmZone(null); setConfirmInput(""); }}>
|
||||
<div className="modal" style={{maxWidth:440}} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--red)"}}>⚠ Supprimer la zone</div>
|
||||
<button className="modal-close" onClick={() => { setConfirmZone(null); setConfirmInput(""); }}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
|
||||
Cette action supprimera <strong style={{color:"var(--text-0)"}}>définitivement</strong> la zone
|
||||
et tous ses enregistrements. Elle est <strong style={{color:"var(--red)"}}>irréversible</strong>.
|
||||
</p>
|
||||
<div style={{
|
||||
margin:"14px 0 6px",padding:"10px 14px",
|
||||
background:"var(--bg-0)",border:"1px solid var(--border)",borderRadius:6,
|
||||
fontFamily:"var(--font-mono)",fontSize:13,color:"var(--accent)",
|
||||
}}>
|
||||
{confirmZone}
|
||||
</div>
|
||||
<div className="form-group" style={{marginTop:14}}>
|
||||
<label className="form-label" style={{color:"var(--text-1)"}}>
|
||||
Pour confirmer, recopiez le nom de la zone ci-dessous :
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder={confirmZone}
|
||||
value={confirmInput}
|
||||
onChange={(e) => setConfirmInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && confirmInput.trim() === confirmZone && deleteZone(confirmZone)}
|
||||
autoFocus
|
||||
style={{
|
||||
borderColor: confirmInput.length > 0
|
||||
? confirmInput.trim() === confirmZone ? "var(--red)" : "var(--border)"
|
||||
: "var(--border)"
|
||||
}}
|
||||
/>
|
||||
{confirmInput.length > 0 && confirmInput.trim() !== confirmZone && (
|
||||
<div style={{fontSize:11,color:"var(--text-3)",marginTop:4}}>
|
||||
Le nom ne correspond pas encore…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => { setConfirmZone(null); setConfirmInput(""); }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteZone(confirmZone)}
|
||||
disabled={confirmInput.trim() !== confirmZone}
|
||||
style={{opacity: confirmInput.trim() === confirmZone ? 1 : 0.4, cursor: confirmInput.trim() === confirmZone ? "pointer" : "not-allowed"}}
|
||||
>
|
||||
Supprimer définitivement
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record Toggle Confirmation */}
|
||||
{confirmToggle && (
|
||||
<div className="modal-overlay" onClick={() => setConfirmToggle(null)}>
|
||||
<div className="modal" style={{maxWidth:420}} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color: confirmToggle.active === false ? "var(--green)" : "var(--yellow)"}}>
|
||||
{confirmToggle.active === false ? "Activer l'enregistrement" : "Désactiver l'enregistrement"}
|
||||
</div>
|
||||
<button className="modal-close" onClick={() => setConfirmToggle(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{
|
||||
background:"var(--bg-0)",border:"1px solid var(--border)",
|
||||
borderRadius:6,padding:"10px 14px",fontFamily:"var(--font-mono)",
|
||||
fontSize:12,marginBottom:12,
|
||||
}}>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:6}}>
|
||||
<span className={`badge badge-${(confirmToggle.type||"").toLowerCase()}`}>
|
||||
{confirmToggle.type}
|
||||
</span>
|
||||
<span style={{color:"var(--text-0)",fontWeight:500}}>{confirmToggle.name || "@"}</span>
|
||||
<span style={{color:"var(--text-2)"}}>TTL {confirmToggle.ttl ?? "—"}s</span>
|
||||
</div>
|
||||
<div style={{color:"var(--text-1)",wordBreak:"break-all"}}>{confirmToggle.value}</div>
|
||||
</div>
|
||||
{confirmToggle.active === false ? (
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.6}}>
|
||||
Cet enregistrement sera <strong style={{color:"var(--green)"}}>réactivé</strong> et
|
||||
appliqué sur le serveur BIND.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.6}}>
|
||||
Cet enregistrement sera <strong style={{color:"var(--yellow)"}}>désactivé</strong> et
|
||||
mis en commentaire dans le fichier de zone{" "}
|
||||
<span style={{fontFamily:"var(--font-mono)",fontSize:11,color:"var(--text-2)"}}>
|
||||
(; [DISABLED] …)
|
||||
</span>
|
||||
. Il ne sera plus résolu par BIND.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setConfirmToggle(null)}>Annuler</button>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: confirmToggle.active === false ? "var(--green)" : "var(--yellow)",
|
||||
color: "var(--bg-0)", fontWeight: 600,
|
||||
}}
|
||||
onClick={doToggleRecord}
|
||||
>
|
||||
{confirmToggle.active === false ? "● Activer" : "○ Désactiver"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record Delete Confirmation */}
|
||||
{confirmRec && (
|
||||
<div className="modal-overlay" onClick={() => setConfirmRec(null)}>
|
||||
<div className="modal" style={{maxWidth:400}} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer l'enregistrement</div>
|
||||
<button className="modal-close" onClick={() => setConfirmRec(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7,marginBottom:12}}>
|
||||
Supprimer définitivement cet enregistrement ?
|
||||
</p>
|
||||
<div style={{
|
||||
background:"var(--bg-0)",border:"1px solid var(--border)",
|
||||
borderRadius:6,padding:"10px 14px",fontFamily:"var(--font-mono)",fontSize:12,
|
||||
}}>
|
||||
<div style={{display:"flex",gap:10,alignItems:"center",flexWrap:"wrap"}}>
|
||||
<span className={`badge badge-${confirmRec.type.toLowerCase()}`} style={{textTransform:"uppercase"}}>
|
||||
{confirmRec.type}
|
||||
</span>
|
||||
<span style={{color:"var(--text-0)",fontWeight:500}}>{confirmRec.name||"@"}</span>
|
||||
<span style={{color:"var(--text-2)"}}>TTL {confirmRec.ttl}s</span>
|
||||
</div>
|
||||
<div style={{color:"var(--text-1)",marginTop:6,wordBreak:"break-all"}}>
|
||||
{confirmRec.priority != null && <span style={{color:"var(--yellow)",marginRight:8}}>prio {confirmRec.priority}</span>}
|
||||
{confirmRec.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setConfirmRec(null)}>Annuler</button>
|
||||
<button className="btn btn-danger" onClick={doDeleteRecord}>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone Config Modal */}
|
||||
{showZoneConfig && (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setShowZoneConfig(false)}>
|
||||
<div className="modal" style={{maxWidth:460}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<span style={{color:"var(--accent)"}}>⚙</span> Configuration — {activeZone}
|
||||
</div>
|
||||
<button className="modal-close" onClick={() => setShowZoneConfig(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{zoneConfigError && <div className="alert alert-error" style={{marginBottom:0}}>⚠ {zoneConfigError}</div>}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">TTL par défaut (s)</label>
|
||||
<input className="form-input" type="number" min="1"
|
||||
value={zoneConfig.ttl}
|
||||
onChange={e => setZoneConfig({...zoneConfig, ttl: +e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email admin SOA</label>
|
||||
<input className="form-input" placeholder="hostmaster.example.com"
|
||||
value={zoneConfig.admin}
|
||||
onChange={e => setZoneConfig({...zoneConfig, admin: e.target.value.replace("@", ".")})} />
|
||||
<div style={{fontSize:10, color:"var(--text-3)", marginTop:2}}>Sans arobase (@→.)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{margin:"10px 0 6px",fontSize:10,color:"var(--text-2)",
|
||||
letterSpacing:"0.08em",textTransform:"uppercase"}}>Paramètres SOA</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Refresh (s)</label>
|
||||
<input className="form-input" type="number" min="1"
|
||||
value={zoneConfig.refresh}
|
||||
onChange={e => setZoneConfig({...zoneConfig, refresh: +e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Retry (s)</label>
|
||||
<input className="form-input" type="number" min="1"
|
||||
value={zoneConfig.retry}
|
||||
onChange={e => setZoneConfig({...zoneConfig, retry: +e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Expire (s)</label>
|
||||
<input className="form-input" type="number" min="1"
|
||||
value={zoneConfig.expire}
|
||||
onChange={e => setZoneConfig({...zoneConfig, expire: +e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Negative TTL (s)</label>
|
||||
<input className="form-input" type="number" min="1"
|
||||
value={zoneConfig.negative_ttl}
|
||||
onChange={e => setZoneConfig({...zoneConfig, negative_ttl: +e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowZoneConfig(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={saveZoneConfig} disabled={zoneConfigSaving}>
|
||||
{zoneConfigSaving ? "Sauvegarde…" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: "", has_dns: false, has_mail: false,
|
||||
dns_ttl: 3600, dns_admin: "hostmaster",
|
||||
dns_refresh: 43200, dns_retry: 3600, dns_expire: 2419200, dns_negative_ttl: 3600,
|
||||
max_accounts: 0, max_quota_mb: 0, max_lists: 0,
|
||||
};
|
||||
|
||||
function LimitInput({ label, value, onChange, hint }) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label className="form-label">{label}</label>
|
||||
<input className="form-input" type="number" min="0" value={value}
|
||||
onChange={e => onChange(+e.target.value)} />
|
||||
{hint && <div style={{fontSize:10,color:"var(--text-3)",marginTop:3}}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBar({ used, max, label }) {
|
||||
if (max === 0) return <span style={{fontSize:11,color:"var(--text-3)"}}>Illimité</span>;
|
||||
const pct = Math.min(100, Math.round((used / max) * 100));
|
||||
const col = pct > 80 ? "var(--red)" : pct > 60 ? "var(--yellow)" : "var(--green)";
|
||||
return (
|
||||
<div style={{minWidth:100}}>
|
||||
<div style={{height:4,background:"var(--bg-3)",borderRadius:2,overflow:"hidden",marginBottom:3}}>
|
||||
<div style={{height:"100%",width:`${pct}%`,background:col,borderRadius:2,transition:"width 0.3s"}}/>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--text-2)"}}>{label}: {used}/{max}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceSection({ title, active, onToggle, color, children }) {
|
||||
return (
|
||||
<div style={{border:`1px solid ${active ? color+"44":"var(--border)"}`,borderRadius:6,overflow:"hidden",marginBottom:8}}>
|
||||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
|
||||
padding:"10px 14px",background:active?color+"11":"var(--bg-2)",cursor:"pointer"}}
|
||||
onClick={onToggle}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:active?color:"var(--text-2)"}}>{title}</div>
|
||||
<div style={{width:36,height:18,borderRadius:9,background:active?color:"var(--bg-3)",
|
||||
position:"relative",transition:"background 0.2s",border:`1px solid ${active?color:"var(--border)"}`}}>
|
||||
<div style={{position:"absolute",top:2,left:active?18:2,width:12,height:12,
|
||||
borderRadius:"50%",background:active?"var(--bg-0)":"var(--text-3)",transition:"left 0.2s"}}/>
|
||||
</div>
|
||||
</div>
|
||||
{active && <div style={{padding:"12px 14px",borderTop:`1px solid ${color}22`}}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsFields({ v, set }) {
|
||||
return (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">TTL par défaut (s)</label>
|
||||
<input className="form-input" type="number" min="1" value={v.dns_ttl}
|
||||
onChange={e => set({...v, dns_ttl:+e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email admin SOA</label>
|
||||
<input className="form-input" placeholder="hostmaster.example.com"
|
||||
value={v.dns_admin}
|
||||
onChange={e => set({...v, dns_admin:e.target.value.replace("@",".")})} />
|
||||
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>Sans arobase (@→.)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Refresh (s)</label>
|
||||
<input className="form-input" type="number" min="1" value={v.dns_refresh}
|
||||
onChange={e => set({...v, dns_refresh:+e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Retry (s)</label>
|
||||
<input className="form-input" type="number" min="1" value={v.dns_retry}
|
||||
onChange={e => set({...v, dns_retry:+e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Expire (s)</label>
|
||||
<input className="form-input" type="number" min="1" value={v.dns_expire}
|
||||
onChange={e => set({...v, dns_expire:+e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Negative TTL (s)</label>
|
||||
<input className="form-input" type="number" min="1" value={v.dns_negative_ttl}
|
||||
onChange={e => set({...v, dns_negative_ttl:+e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MailFields({ v, set }) {
|
||||
return (
|
||||
<>
|
||||
<div style={{fontSize:11,color:"var(--text-2)",marginBottom:8}}>0 = illimité</div>
|
||||
<div className="form-row">
|
||||
<LimitInput label="Max comptes" value={v.max_accounts} onChange={x => set({...v,max_accounts:x})} />
|
||||
<LimitInput label="Quota global (Mo)" value={v.max_quota_mb} onChange={x => set({...v,max_quota_mb:x})} />
|
||||
</div>
|
||||
<LimitInput label="Max listes de diffusion" value={v.max_lists} onChange={x => set({...v,max_lists:x})} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainsManager({ apiBase, user, token, onUnauthorized }) {
|
||||
const [domains, setDomains] = useState([]);
|
||||
const [dnszones, setDnsZones] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [modalError, setModalError] = useState(null);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editName, setEditName] = useState(null);
|
||||
const [editForm, setEditForm] = useState({});
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [confirmInput, setConfirmInput] = useState("");
|
||||
|
||||
const isAdmin = user?.roles?.some(r => ["global_admin","domain_admin"].includes(r.role));
|
||||
|
||||
const api = useCallback(async (path, options = {}) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: {"Content-Type":"application/json","Authorization":`Bearer ${token}`},
|
||||
...options,
|
||||
});
|
||||
if (r.status === 401) { onUnauthorized?.(); throw new Error("Session expirée."); }
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({detail:r.statusText}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
if (r.status === 204) return null;
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([api("/mail/domains-config"), api("/dns/zones")])
|
||||
.then(([mdoms, dzones]) => { setDomains(mdoms); setDnsZones(dzones); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const createDomain = async () => {
|
||||
const name = form.name.trim();
|
||||
if (!name) { setModalError("Nom de domaine requis"); return; }
|
||||
if (!form.has_dns && !form.has_mail) { setModalError("Activez au moins un service"); return; }
|
||||
setSaving(true); setModalError(null);
|
||||
try {
|
||||
if (form.has_mail) {
|
||||
await api(`/mail/domains-config?name=${encodeURIComponent(name)}`, {
|
||||
method:"POST",
|
||||
body:JSON.stringify({has_mail:true,max_accounts:form.max_accounts,
|
||||
max_quota_mb:form.max_quota_mb,max_lists:form.max_lists}),
|
||||
});
|
||||
}
|
||||
if (form.has_dns) {
|
||||
await api("/dns/zones", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({name, admin:form.dns_admin, ttl:form.dns_ttl,
|
||||
refresh:form.dns_refresh, retry:form.dns_retry,
|
||||
expire:form.dns_expire, negative_ttl:form.dns_negative_ttl}),
|
||||
});
|
||||
}
|
||||
setShowCreate(false); setForm(EMPTY_FORM); load();
|
||||
} catch(e) { setModalError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const openEdit = async (name) => {
|
||||
setEditName(name); setModalError(null); setSaving(true);
|
||||
const md = domains.find(d => d.name === name);
|
||||
const dz = dnszones.find(z => z.name === name);
|
||||
const ef = {
|
||||
has_mail: !!md, has_dns: !!dz,
|
||||
max_accounts: md?.max_accounts ?? 0,
|
||||
max_quota_mb: md?.max_quota_mb ?? 0,
|
||||
max_lists: md?.max_lists ?? 0,
|
||||
dns_ttl: 3600, dns_admin: "hostmaster",
|
||||
dns_refresh: 43200, dns_retry: 3600, dns_expire: 2419200, dns_negative_ttl: 3600,
|
||||
};
|
||||
try {
|
||||
if (dz) {
|
||||
const cfg = await api(`/dns/zones/${name}/config`);
|
||||
ef.dns_ttl = cfg.ttl;
|
||||
ef.dns_admin = cfg.admin;
|
||||
}
|
||||
setEditForm(ef);
|
||||
setShowEdit(true);
|
||||
} catch(e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
setSaving(true); setModalError(null);
|
||||
const name = editName;
|
||||
const md = domains.find(d => d.name === name);
|
||||
const dz = dnszones.find(z => z.name === name);
|
||||
try {
|
||||
// Mail
|
||||
if (editForm.has_mail && !md) {
|
||||
await api(`/mail/domains-config?name=${encodeURIComponent(name)}`, {method:"POST",
|
||||
body:JSON.stringify({has_mail:true,max_accounts:editForm.max_accounts,
|
||||
max_quota_mb:editForm.max_quota_mb,max_lists:editForm.max_lists})});
|
||||
} else if (editForm.has_mail && md) {
|
||||
await api(`/mail/domains-config/${encodeURIComponent(name)}`, {method:"PUT",
|
||||
body:JSON.stringify({has_mail:true,max_accounts:editForm.max_accounts,
|
||||
max_quota_mb:editForm.max_quota_mb,max_lists:editForm.max_lists})});
|
||||
} else if (!editForm.has_mail && md) {
|
||||
await api(`/mail/domains/${encodeURIComponent(name)}`, {method:"DELETE"});
|
||||
}
|
||||
// DNS
|
||||
if (editForm.has_dns && !dz) {
|
||||
await api("/dns/zones", {method:"POST",
|
||||
body:JSON.stringify({name, admin:editForm.dns_admin, ttl:editForm.dns_ttl,
|
||||
refresh:editForm.dns_refresh, retry:editForm.dns_retry,
|
||||
expire:editForm.dns_expire, negative_ttl:editForm.dns_negative_ttl})});
|
||||
} else if (editForm.has_dns && dz) {
|
||||
await api(`/dns/zones/${name}/config`, {method:"PUT",
|
||||
body:JSON.stringify({admin:editForm.dns_admin, ttl:editForm.dns_ttl,
|
||||
refresh:editForm.dns_refresh, retry:editForm.dns_retry,
|
||||
expire:editForm.dns_expire, negative_ttl:editForm.dns_negative_ttl})});
|
||||
} else if (!editForm.has_dns && dz) {
|
||||
await api(`/dns/zones/${name}`, {method:"DELETE"});
|
||||
}
|
||||
setShowEdit(false); load();
|
||||
} catch(e) { setModalError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const deleteDomain = async (name) => {
|
||||
if (confirmDel !== name) { setConfirmDel(name); setConfirmInput(""); return; }
|
||||
if (confirmInput.trim() !== name) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const md = domains.find(d => d.name === name);
|
||||
const dz = dnszones.find(z => z.name === name);
|
||||
if (md) await api(`/mail/domains/${encodeURIComponent(name)}`, {method:"DELETE"});
|
||||
if (dz) await api(`/dns/zones/${name}`, {method:"DELETE"});
|
||||
setConfirmDel(null); setConfirmInput(""); load();
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const allNames = [...new Set([...domains.map(d=>d.name),...dnszones.map(z=>z.name)])].sort();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">◇</span> Domaines</div>
|
||||
<div className="page-sub">Gestion des domaines et de leurs services</div>
|
||||
</div>
|
||||
{error && <div className="alert alert-error">⚠ {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}>✕</button></div>}
|
||||
{isAdmin && <div style={{display:"flex",justifyContent:"flex-end",marginBottom:12}}>
|
||||
<button className="btn btn-primary" onClick={()=>{setForm(EMPTY_FORM);setModalError(null);setShowCreate(true);}}>+ Nouveau domaine</button>
|
||||
</div>}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><div className="card-title">◇ Domaines ({allNames.length})</div></div>
|
||||
<div className="card-body">
|
||||
{loading ? <div className="loading"><div className="spinner"/> Chargement…</div>
|
||||
: allNames.length === 0 ? <div className="empty-state"><div className="empty-icon">◇</div><div className="empty-text">Aucun domaine configuré</div></div>
|
||||
: (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Domaine</th><th>Services</th><th>DNS</th>
|
||||
<th>Comptes mail</th><th>Quota global</th><th>Listes</th>
|
||||
{isAdmin && <th></th>}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{allNames.map(name => {
|
||||
const md = domains.find(d => d.name === name);
|
||||
const dz = dnszones.find(z => z.name === name);
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className="cell-mono" style={{fontWeight:500,color:"var(--text-0)"}}>{name}</td>
|
||||
<td><div style={{display:"flex",gap:4}}>
|
||||
{dz && <span className="badge badge-a">DNS</span>}
|
||||
{md && <span className="badge badge-mx">MAIL</span>}
|
||||
{!dz && !md && <span style={{color:"var(--text-3)",fontSize:11}}>—</span>}
|
||||
</div></td>
|
||||
<td className="cell-dim">{dz?`${dz.record_count} enreg.`:"—"}</td>
|
||||
<td>{md?<UsageBar used={md.account_count} max={md.max_accounts} label="comptes"/>:<span style={{color:"var(--text-3)",fontSize:11}}>—</span>}</td>
|
||||
<td>{md?<UsageBar used={md.used_quota_mb} max={md.max_quota_mb} label="Mo"/>:<span style={{color:"var(--text-3)",fontSize:11}}>—</span>}</td>
|
||||
<td>{md?<UsageBar used={md.list_count} max={md.max_lists} label="listes"/>:<span style={{color:"var(--text-3)",fontSize:11}}>—</span>}</td>
|
||||
{isAdmin && <td><div className="cell-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={()=>openEdit(name)} title="Configurer">⚙</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={()=>deleteDomain(name)} title="Supprimer">✕</button>
|
||||
</div></td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowCreate(false)}>
|
||||
<div className="modal" style={{width:540}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">Nouveau domaine</div>
|
||||
<button className="modal-close" onClick={()=>setShowCreate(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{modalError && <div className="alert alert-error" style={{marginBottom:0}}>⚠ {modalError}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nom du domaine</label>
|
||||
<input className="form-input" placeholder="example.com" value={form.name}
|
||||
onChange={e=>setForm({...form,name:e.target.value})} autoFocus />
|
||||
</div>
|
||||
<div style={{margin:"14px 0 6px",fontSize:11,color:"var(--text-2)",letterSpacing:"0.08em",textTransform:"uppercase"}}>Services</div>
|
||||
<ServiceSection title="◈ Zone DNS" active={form.has_dns} color="var(--accent)"
|
||||
onToggle={()=>setForm({...form,has_dns:!form.has_dns})}>
|
||||
<DnsFields v={form} set={setForm}/>
|
||||
</ServiceSection>
|
||||
<ServiceSection title="◉ Mail" active={form.has_mail} color="var(--yellow)"
|
||||
onToggle={()=>setForm({...form,has_mail:!form.has_mail})}>
|
||||
<MailFields v={form} set={setForm}/>
|
||||
</ServiceSection>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>setShowCreate(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={createDomain} disabled={saving}>{saving?"Création…":"Créer"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{showEdit && editName && (
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowEdit(false)}>
|
||||
<div className="modal" style={{width:540}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title"><span style={{color:"var(--accent)"}}>⚙</span> {editName}</div>
|
||||
<button className="modal-close" onClick={()=>setShowEdit(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{modalError && <div className="alert alert-error" style={{marginBottom:0}}>⚠ {modalError}</div>}
|
||||
<div style={{marginBottom:6,fontSize:11,color:"var(--text-2)",letterSpacing:"0.08em",textTransform:"uppercase"}}>Services actifs</div>
|
||||
<ServiceSection title="◈ Zone DNS" active={editForm.has_dns} color="var(--accent)"
|
||||
onToggle={()=>setEditForm({...editForm,has_dns:!editForm.has_dns})}>
|
||||
<DnsFields v={editForm} set={setEditForm}/>
|
||||
</ServiceSection>
|
||||
<ServiceSection title="◉ Mail" active={editForm.has_mail} color="var(--yellow)"
|
||||
onToggle={()=>setEditForm({...editForm,has_mail:!editForm.has_mail})}>
|
||||
<MailFields v={editForm} set={setEditForm}/>
|
||||
</ServiceSection>
|
||||
{(!editForm.has_dns||!editForm.has_mail)&&(
|
||||
<div style={{fontSize:11,color:"var(--yellow)",marginTop:8,
|
||||
padding:"6px 10px",background:"var(--yellow-dim)",borderRadius:4}}>
|
||||
⚠ Désactiver un service supprimera ses données.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>setShowEdit(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={saveEdit} disabled={saving}>{saving?"Sauvegarde…":"Enregistrer"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirmDel && (
|
||||
<div className="modal-overlay" onClick={()=>{setConfirmDel(null);setConfirmInput("");}}>
|
||||
<div className="modal" style={{maxWidth:440}} onClick={e=>e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--red)"}}>⚠ Supprimer le domaine</div>
|
||||
<button className="modal-close" onClick={()=>{setConfirmDel(null);setConfirmInput("");}}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
|
||||
Supprime <strong>tous les services</strong> (DNS + mail) de{" "}
|
||||
<strong style={{color:"var(--accent)"}}>{confirmDel}</strong>. Action irréversible.
|
||||
</p>
|
||||
<div className="form-group" style={{marginTop:14}}>
|
||||
<label className="form-label">Recopiez le nom du domaine :</label>
|
||||
<input className="form-input" placeholder={confirmDel} value={confirmInput}
|
||||
onChange={e=>setConfirmInput(e.target.value)} autoFocus/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>{setConfirmDel(null);setConfirmInput("");}}>Annuler</button>
|
||||
<button className="btn btn-danger" onClick={()=>deleteDomain(confirmDel)}
|
||||
disabled={confirmInput.trim()!==confirmDel}
|
||||
style={{opacity:confirmInput.trim()===confirmDel?1:0.4}}>
|
||||
Supprimer définitivement
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// ── PKCE helpers ─────────────────────────────────────────────────────────
|
||||
function base64url(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
async function generatePKCE() {
|
||||
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
||||
return { verifier, challenge: base64url(digest) };
|
||||
}
|
||||
function buildAuthUrl(cfg, challenge, state) {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: cfg.client_id,
|
||||
redirect_uri: cfg.redirect_uri,
|
||||
scope: cfg.scopes || "openid email profile",
|
||||
state,
|
||||
...(cfg.use_pkce ? { code_challenge: challenge, code_challenge_method: "S256" } : {}),
|
||||
});
|
||||
return `${cfg.authorization_endpoint}?${params}`;
|
||||
}
|
||||
|
||||
// ── TOTP verify step (TOTP already configured) ───────────────────────────
|
||||
function TotpVerifyStep({ apiBase, pendingToken, onSuccess, onBack }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (code.length !== 6) return;
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/auth/totp/validate-login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${pendingToken}` },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.detail || "Code invalide");
|
||||
// validation succeeded — give token + user to parent
|
||||
onSuccess(data.token, data.user);
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY_STYLE}>
|
||||
<div style={CARD_STYLE}>
|
||||
<div style={BRAND_STYLE}>
|
||||
<div style={{fontSize:40}}>🔐</div>
|
||||
<div style={{fontFamily:"var(--font-display)", fontSize:20, fontWeight:800, marginTop:8}}>
|
||||
Vérification 2FA
|
||||
</div>
|
||||
<div style={{fontSize:12, color:"var(--text-2)", marginTop:4}}>
|
||||
Saisissez le code affiché par votre application d'authentification
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert msg={error} />}
|
||||
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
onKeyDown={e => e.key === "Enter" && code.length === 6 && submit()}
|
||||
style={{ textAlign:"center", fontFamily:"var(--font-mono)", fontSize:24,
|
||||
letterSpacing:"0.25em", marginBottom:16 }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<button className="btn btn-primary"
|
||||
onClick={submit}
|
||||
disabled={loading || code.length !== 6}
|
||||
style={FULL_BTN}>
|
||||
{loading ? <Spinner /> : "Vérifier"}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost btn-sm" style={{marginTop:10, width:"100%"}} onClick={onBack}>
|
||||
← Retour à la connexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TOTP setup step (TOTP required but not yet configured) ───────────────
|
||||
function TotpSetupStep({ apiBase, pendingToken, onSuccess, onBack }) {
|
||||
const [step, setStep] = useState("qr"); // qr | verify
|
||||
const [qrData, setQrData] = useState(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${apiBase}/auth/totp/setup`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${pendingToken}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => setQrData(d))
|
||||
.catch(e => setError(e.message));
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
const verify = async () => {
|
||||
if (code.length !== 6) return;
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
// Step 1: activate TOTP with the code
|
||||
const r1 = await fetch(`${apiBase}/auth/totp/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${pendingToken}` },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const d1 = await r1.json();
|
||||
if (!r1.ok) throw new Error(d1.detail || "Code invalide — vérifiez l'heure de votre appareil");
|
||||
|
||||
// Step 2: upgrade session (TOTP is now active, no need to re-enter code)
|
||||
const r2 = await fetch(`${apiBase}/auth/totp/session-upgrade`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${pendingToken}` },
|
||||
});
|
||||
const d2 = await r2.json();
|
||||
if (!r2.ok) throw new Error(d2.detail || "Erreur de session");
|
||||
onSuccess(d2.token, d2.user);
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY_STYLE}>
|
||||
<div style={{...CARD_STYLE, maxWidth:420}}>
|
||||
<div style={BRAND_STYLE}>
|
||||
<div style={{fontSize:36}}>🔐</div>
|
||||
<div style={{fontFamily:"var(--font-display)", fontSize:18, fontWeight:800, marginTop:8}}>
|
||||
Configuration requise
|
||||
</div>
|
||||
<div style={{fontSize:12, color:"var(--yellow)", marginTop:4}}>
|
||||
Votre administrateur exige la double authentification
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert msg={error} />}
|
||||
|
||||
{!qrData && !error && (
|
||||
<div style={{textAlign:"center", padding:"20px 0"}}>
|
||||
<div className="spinner" style={{margin:"0 auto"}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrData && (
|
||||
<>
|
||||
<p style={{fontSize:12, color:"var(--text-1)", lineHeight:1.7, marginBottom:12}}>
|
||||
<strong>Étape 1</strong> — Scannez ce QR code avec votre application
|
||||
(Google Authenticator, Aegis, Authy…)
|
||||
</p>
|
||||
<div style={{textAlign:"center", margin:"0 0 12px"}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${qrData.qr_b64}`}
|
||||
alt="QR TOTP"
|
||||
style={{width:180, height:180, imageRendering:"pixelated",
|
||||
border:"4px solid white", borderRadius:4}}
|
||||
/>
|
||||
</div>
|
||||
<details style={{marginBottom:14}}>
|
||||
<summary style={{fontSize:11, color:"var(--text-3)", cursor:"pointer"}}>
|
||||
Afficher la clé secrète
|
||||
</summary>
|
||||
<div style={{fontFamily:"var(--font-mono)", fontSize:12, letterSpacing:"0.1em",
|
||||
padding:"6px 10px", background:"var(--bg-0)", borderRadius:4,
|
||||
marginTop:6, wordBreak:"break-all", color:"var(--accent)"}}>
|
||||
{qrData.secret}
|
||||
</div>
|
||||
</details>
|
||||
<p style={{fontSize:12, color:"var(--text-1)", lineHeight:1.7, marginBottom:8}}>
|
||||
<strong>Étape 2</strong> — Saisissez le code affiché pour confirmer.
|
||||
</p>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
onKeyDown={e => e.key === "Enter" && code.length === 6 && verify()}
|
||||
style={{ textAlign:"center", fontFamily:"var(--font-mono)", fontSize:20,
|
||||
letterSpacing:"0.2em", marginBottom:12 }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn btn-primary"
|
||||
onClick={verify}
|
||||
disabled={loading || code.length !== 6}
|
||||
style={FULL_BTN}>
|
||||
{loading ? <Spinner /> : "Activer et continuer"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="btn btn-ghost btn-sm" style={{marginTop:10, width:"100%"}} onClick={onBack}>
|
||||
← Retour à la connexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared UI helpers ────────────────────────────────────────────────────
|
||||
const OVERLAY_STYLE = {
|
||||
minHeight:"100vh", background:"var(--bg-0)",
|
||||
display:"flex", alignItems:"center", justifyContent:"center",
|
||||
};
|
||||
const CARD_STYLE = {
|
||||
width:380, maxWidth:"94vw",
|
||||
background:"var(--bg-1)", border:"1px solid var(--border)",
|
||||
borderRadius:12, padding:"36px 32px",
|
||||
boxShadow:"0 24px 80px rgba(0,0,0,0.5)",
|
||||
};
|
||||
const BRAND_STYLE = { textAlign:"center", marginBottom:24 };
|
||||
const FULL_BTN = { width:"100%", justifyContent:"center", padding:"10px" };
|
||||
|
||||
function Alert({ msg }) {
|
||||
return (
|
||||
<div className="alert alert-error" style={{marginBottom:14}}>⚠ {msg}</div>
|
||||
);
|
||||
}
|
||||
function Spinner() {
|
||||
return <span className="spinner" style={{width:14, height:14, borderWidth:2}} />;
|
||||
}
|
||||
|
||||
// ── Main login page ───────────────────────────────────────────────────────
|
||||
export default function LoginPage({ apiBase, onLogin }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [oidcCfg, setOidcCfg] = useState(null);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
|
||||
// TOTP pending state — set when backend returns totp_pending=true
|
||||
// The token is stored here temporarily, NOT in localStorage
|
||||
const [totpState, setTotpState] = useState(null); // null | "verify" | "setup"
|
||||
const [pendingToken, setPendingToken] = useState(null);
|
||||
|
||||
const resetTotp = () => { setTotpState(null); setPendingToken(null); };
|
||||
|
||||
// Called by TotpVerifyStep or TotpSetupStep when complete
|
||||
const onTotpSuccess = useCallback((token, user) => {
|
||||
onLogin(token, user);
|
||||
}, [onLogin]);
|
||||
|
||||
// Fetch OIDC config on mount
|
||||
useEffect(() => {
|
||||
fetch(`${apiBase}/auth/config`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (d) setOidcCfg(d);
|
||||
// If no response, keep null so we don't show/hide incorrectly
|
||||
})
|
||||
.catch(() => {
|
||||
// Network error — keep null, OIDC section stays hidden
|
||||
setOidcCfg({ enabled: false });
|
||||
});
|
||||
}, [apiBase]);
|
||||
|
||||
// Handle Keycloak redirect-back
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
if (!code) return;
|
||||
|
||||
const savedState = sessionStorage.getItem("oidc_state");
|
||||
const codeVerifier = sessionStorage.getItem("oidc_verifier");
|
||||
const savedRedirect = sessionStorage.getItem("oidc_redirect_uri");
|
||||
sessionStorage.removeItem("oidc_state");
|
||||
sessionStorage.removeItem("oidc_verifier");
|
||||
sessionStorage.removeItem("oidc_redirect_uri");
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
|
||||
if (state !== savedState) {
|
||||
setError("Erreur OIDC : state invalide. Réessayez.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true); setError(null);
|
||||
fetch(`${apiBase}/auth/oidc/callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, redirect_uri: savedRedirect, code_verifier: codeVerifier }),
|
||||
})
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, data: d })))
|
||||
.then(({ ok, data }) => {
|
||||
if (!ok) throw new Error(data.detail || "Erreur SSO");
|
||||
handleLoginResponse(data);
|
||||
})
|
||||
.catch(e => { setError(e.message); setLoading(false); });
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Common handler for both local and OIDC login responses
|
||||
const handleLoginResponse = useCallback((data) => {
|
||||
if (data.totp_pending) {
|
||||
setPendingToken(data.token);
|
||||
// totp_setup_required = must configure TOTP first
|
||||
// totp_pending alone = must verify existing TOTP
|
||||
setTotpState(data.totp_setup_required ? "setup" : "verify");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// No TOTP needed — login complete
|
||||
onLogin(data.token, data.user);
|
||||
setLoading(false);
|
||||
}, [onLogin]);
|
||||
|
||||
// Local login
|
||||
const submit = async () => {
|
||||
if (!username || !password) { setError("Identifiants requis"); return; }
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.detail || "Identifiant ou mot de passe incorrect");
|
||||
handleLoginResponse(data);
|
||||
} catch(e) {
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keycloak SSO
|
||||
const startOidc = useCallback(async () => {
|
||||
if (!oidcCfg?.authorization_endpoint) return;
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
sessionStorage.setItem("oidc_state", state);
|
||||
sessionStorage.setItem("oidc_verifier", verifier);
|
||||
sessionStorage.setItem("oidc_redirect_uri", oidcCfg.redirect_uri);
|
||||
window.location.href = buildAuthUrl(oidcCfg, challenge, state);
|
||||
} catch(e) {
|
||||
setError("Erreur SSO : " + e.message);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}, [oidcCfg]);
|
||||
|
||||
// ── TOTP steps (before login completes) ──────────────────────────────
|
||||
if (totpState === "verify" && pendingToken) {
|
||||
return (
|
||||
<TotpVerifyStep
|
||||
apiBase={apiBase}
|
||||
pendingToken={pendingToken}
|
||||
onSuccess={onTotpSuccess}
|
||||
onBack={resetTotp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (totpState === "setup" && pendingToken) {
|
||||
return (
|
||||
<TotpSetupStep
|
||||
apiBase={apiBase}
|
||||
pendingToken={pendingToken}
|
||||
onSuccess={onTotpSuccess}
|
||||
onBack={resetTotp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Normal login form ────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={OVERLAY_STYLE}>
|
||||
<div style={CARD_STYLE}>
|
||||
{/* Brand */}
|
||||
<div style={BRAND_STYLE}>
|
||||
<div style={{fontSize:40, color:"var(--accent)", filter:"drop-shadow(0 0 12px var(--accent-glow))"}}>⬡</div>
|
||||
<div style={{fontFamily:"var(--font-display)", fontSize:22, fontWeight:800, marginTop:8}}>NetAdmin</div>
|
||||
<div style={{fontSize:11, color:"var(--text-2)", letterSpacing:"0.1em", textTransform:"uppercase", marginTop:4}}>
|
||||
DNS & Mail Console
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert msg={error} />}
|
||||
|
||||
{/* Keycloak SSO */}
|
||||
{oidcCfg?.enabled && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={startOidc}
|
||||
disabled={oidcLoading || !oidcCfg.authorization_endpoint}
|
||||
style={{...FULL_BTN, marginBottom:8}}>
|
||||
{oidcLoading ? <><Spinner /> Redirection…</> : "🔐 Se connecter via Keycloak"}
|
||||
</button>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10, margin:"14px 0",
|
||||
color:"var(--text-3)", fontSize:11}}>
|
||||
<div style={{flex:1, height:1, background:"var(--border)"}} />
|
||||
ou connexion locale
|
||||
<div style={{flex:1, height:1, background:"var(--border)"}} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Local form */}
|
||||
<div className="form-group" style={{marginBottom:12}}>
|
||||
<label className="form-label">Identifiant</label>
|
||||
<input className="form-input" placeholder="Identifiant"
|
||||
value={username} onChange={e => setUsername(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && submit()}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="form-group" style={{marginBottom:22}}>
|
||||
<label className="form-label">Mot de passe</label>
|
||||
<div style={{position:"relative"}}>
|
||||
<input className="form-input"
|
||||
type={showPw ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && submit()}
|
||||
style={{paddingRight:38}} />
|
||||
<button onClick={() => setShowPw(!showPw)} style={{
|
||||
position:"absolute", right:8, top:"50%", transform:"translateY(-50%)",
|
||||
background:"none", border:"none", color:"var(--text-2)", cursor:"pointer", fontSize:14,
|
||||
}}>
|
||||
{showPw ? "🙈" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-secondary" onClick={submit} disabled={loading} style={FULL_BTN}>
|
||||
{loading ? <><Spinner /> Connexion…</> : "Se connecter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import SyncToast from "./SyncToast";
|
||||
|
||||
const EMPTY_ACC = { username:"", domain:"", password:"", quota:1024, active:true };
|
||||
const EMPTY_ALIAS = { source:"", destination:"", domain:"" };
|
||||
|
||||
function QuotaBar({ used, total }) {
|
||||
if (!total) return <span style={{fontSize:11,color:"var(--text-3)"}}>Illimité</span>;
|
||||
const pct = Math.min(100, Math.round((used / total) * 100));
|
||||
const col = pct > 80 ? "var(--red)" : pct > 60 ? "var(--yellow)" : "var(--green)";
|
||||
return (
|
||||
<div style={{minWidth:110}}>
|
||||
<div style={{height:4,background:"var(--bg-3)",borderRadius:2,overflow:"hidden",marginBottom:3}}>
|
||||
<div style={{height:"100%",width:`${pct}%`,background:col,borderRadius:2,transition:"width 0.3s"}}/>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--text-2)"}}>{used} / {total} Mo ({pct}%)</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingBanner({ pending, onRetry, onDismiss, isGlobalAdmin }) {
|
||||
if (!pending.length) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background:"var(--yellow-dim)", border:"1px solid rgba(255,200,0,0.3)",
|
||||
borderRadius:6, padding:"10px 14px", marginBottom:12,
|
||||
}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:6}}>
|
||||
<span style={{fontSize:13,color:"var(--yellow)",fontWeight:600}}>
|
||||
⏳ {pending.length} action{pending.length>1?"s":""} en attente
|
||||
</span>
|
||||
<span style={{fontSize:11,color:"var(--text-2)"}}>
|
||||
— L'agent mail était injoignable au moment de l'opération
|
||||
</span>
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:4}}>
|
||||
{pending.map(p => (
|
||||
<div key={p.id} style={{
|
||||
display:"flex",alignItems:"center",gap:8,
|
||||
background:"var(--bg-1)",borderRadius:4,padding:"6px 10px",
|
||||
fontSize:12,
|
||||
}}>
|
||||
<span style={{
|
||||
padding:"1px 6px",borderRadius:3,fontSize:10,fontWeight:600,
|
||||
background: p.status==="failed"?"var(--red-dim)":"var(--yellow-dim)",
|
||||
color: p.status==="failed"?"var(--red)":"var(--yellow)",
|
||||
}}>{p.status}</span>
|
||||
<span style={{color:"var(--text-1)",fontFamily:"var(--font-mono)",flex:1}}>
|
||||
{p.action}
|
||||
</span>
|
||||
<span style={{color:"var(--text-3)",fontSize:10}}>
|
||||
{new Date(p.created_at).toLocaleString("fr-FR")}
|
||||
</span>
|
||||
{isGlobalAdmin && <>
|
||||
<button className="btn btn-ghost btn-sm" style={{fontSize:11}}
|
||||
onClick={()=>onRetry(p.id)} title="Relancer">↺ Réessayer</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--text-3)"}}
|
||||
onClick={()=>onDismiss(p.id)} title="Ignorer">✕</button>
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MailManager({ apiBase, token, user, onUnauthorized }) {
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [domains, setDomains] = useState([]);
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [pending, setPending] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [modalErr, setModalErr] = useState(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState(null);
|
||||
const [syncSuccess, setSyncSuccess] = useState(true);
|
||||
const [tab, setTab] = useState("accounts");
|
||||
|
||||
// Shared domain filter across tabs
|
||||
const [selDomain, setSelDomain] = useState(null); // null = not yet set
|
||||
|
||||
// Accounts
|
||||
const [accSearch, setAccSearch] = useState("");
|
||||
const [showAccModal, setShowAccModal] = useState(false);
|
||||
const [editAcc, setEditAcc] = useState(null);
|
||||
const [accForm, setAccForm] = useState(EMPTY_ACC);
|
||||
const [confirmDelAcc, setConfirmDelAcc] = useState(null);
|
||||
|
||||
// Aliases
|
||||
const [aliasSearch, setAliasSearch] = useState("");
|
||||
const [showAliasModal, setShowAliasModal] = useState(false);
|
||||
const [editAlias, setEditAlias] = useState(null); // null = new
|
||||
const [aliasForm, setAliasForm] = useState(EMPTY_ALIAS);
|
||||
const [confirmDelAlias, setConfirmDelAlias] = useState(null);
|
||||
|
||||
const isGlobalAdmin = user?.roles?.some(r => r.role === "global_admin");
|
||||
const isAdmin = user?.roles?.some(r =>
|
||||
["global_admin","mail_admin","domain_admin","mail_domain_admin"].includes(r.role)
|
||||
);
|
||||
|
||||
const api = useCallback(async (path, options = {}) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: {"Content-Type":"application/json","Authorization":`Bearer ${token}`},
|
||||
...options,
|
||||
});
|
||||
if (r.status === 401) { onUnauthorized?.(); throw new Error("Session expirée."); }
|
||||
if (r.status === 204) return null;
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(()=>({detail:r.statusText}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api("/mail/accounts"),
|
||||
api("/mail/domains"),
|
||||
api("/mail/aliases"),
|
||||
api("/mail/pending"),
|
||||
])
|
||||
.then(([accs, doms, als, pend]) => {
|
||||
setAccounts(accs);
|
||||
setAliases(als);
|
||||
setPending(pend || []);
|
||||
setDomains(doms);
|
||||
// Set default domain to first one if not yet set
|
||||
setSelDomain(prev => prev && doms.includes(prev) ? prev : (doms[0] || null));
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const syncFromServer = async () => {
|
||||
setSyncing(true); setSyncMsg(null);
|
||||
try {
|
||||
const r = await api("/mail/sync", { method:"POST" });
|
||||
setSyncSuccess(true);
|
||||
setSyncMsg(r.message);
|
||||
load();
|
||||
} catch(e) {
|
||||
setSyncSuccess(false);
|
||||
setSyncMsg(e.message);
|
||||
} finally { setSyncing(false); }
|
||||
};
|
||||
|
||||
const showMutationToast = (result) => {
|
||||
if (result?.queued) {
|
||||
setSyncSuccess(false);
|
||||
setSyncMsg("Enregistré localement. L'agent mail est indisponible — sera appliqué dès sa reconnexion.");
|
||||
} else if (result !== null && result !== undefined) {
|
||||
setSyncSuccess(true);
|
||||
setSyncMsg("Appliqué sur le serveur mail.");
|
||||
}
|
||||
};
|
||||
|
||||
const retryPending = async (id) => {
|
||||
try {
|
||||
await api(`/mail/pending/${id}/retry`, { method:"POST" });
|
||||
load();
|
||||
} catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const dismissPending = async (id) => {
|
||||
try {
|
||||
await api(`/mail/pending/${id}`, { method:"DELETE" });
|
||||
load();
|
||||
} catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*";
|
||||
const pw = Array.from(crypto.getRandomValues(new Uint8Array(16)))
|
||||
.map(b => chars[b % chars.length]).join("");
|
||||
setAccForm(prev => ({...prev, password: pw}));
|
||||
setShowGeneratedPw(true);
|
||||
};
|
||||
|
||||
// ── Account CRUD ──────────────────────────────────────────────────────
|
||||
const openNewAcc = () => {
|
||||
setEditAcc(null);
|
||||
setAccForm({...EMPTY_ACC, domain: selDomain || ""});
|
||||
setModalErr(null); setShowGeneratedPw(false); setShowAccModal(true);
|
||||
};
|
||||
const openEditAcc = (acc) => {
|
||||
setEditAcc(acc);
|
||||
setAccForm({username:acc.username, domain:acc.domain, password:"", quota:acc.quota, active:acc.active});
|
||||
setModalErr(null); setShowGeneratedPw(false); setShowAccModal(true);
|
||||
};
|
||||
const saveAcc = async () => {
|
||||
if (!accForm.domain) { setModalErr("Sélectionnez un domaine"); return; }
|
||||
setSaving(true); setModalErr(null);
|
||||
try {
|
||||
let result;
|
||||
if (editAcc) {
|
||||
result = await api(`/mail/accounts/${editAcc.id}`, {method:"PUT", body:JSON.stringify(accForm)});
|
||||
} else {
|
||||
result = await api("/mail/accounts", {method:"POST", body:JSON.stringify(accForm)});
|
||||
}
|
||||
setShowAccModal(false);
|
||||
showMutationToast(result);
|
||||
load();
|
||||
} catch(e) { setModalErr(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const deleteAcc = async () => {
|
||||
if (!confirmDelAcc) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await api(`/mail/accounts/${confirmDelAcc.id}`, {method:"DELETE"});
|
||||
setConfirmDelAcc(null);
|
||||
showMutationToast(res);
|
||||
load();
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── Alias CRUD ────────────────────────────────────────────────────────
|
||||
// Get all aliases for same source (to populate edit form)
|
||||
const getAliasGroup = (source, domain) =>
|
||||
aliases.filter(a => a.source === source && a.domain === domain);
|
||||
|
||||
const openNewAlias = () => {
|
||||
setEditAlias(null);
|
||||
setAliasForm({source:"", destination:"", domain: selDomain || ""});
|
||||
setModalErr(null); setShowAliasModal(true);
|
||||
};
|
||||
const openEditAlias = (alias) => {
|
||||
// Gather all destinations for this source+domain into a textarea (one per line)
|
||||
const group = getAliasGroup(alias.source, alias.domain);
|
||||
const destinations = group.map(a => a.destination).join("\n");
|
||||
setEditAlias(alias);
|
||||
setAliasForm({source:alias.source, destination:destinations, domain:alias.domain});
|
||||
setModalErr(null); setShowAliasModal(true);
|
||||
};
|
||||
const saveAlias = async () => {
|
||||
if (!aliasForm.domain) { setModalErr("Sélectionnez un domaine"); return; }
|
||||
if (!aliasForm.source.trim()) { setModalErr("Source requise"); return; }
|
||||
if (!aliasForm.destination.trim()) { setModalErr("Destination requise"); return; }
|
||||
// Convert textarea (one per line) to CSV without spaces
|
||||
const csvDest = aliasForm.destination
|
||||
.split(/[\n,]/)
|
||||
.map(d => d.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
setSaving(true); setModalErr(null);
|
||||
try {
|
||||
let aliasResult;
|
||||
if (editAlias) {
|
||||
aliasResult = await api(`/mail/aliases/${editAlias.id}`, {
|
||||
method:"PUT",
|
||||
body:JSON.stringify({...aliasForm, destination:csvDest}),
|
||||
});
|
||||
} else {
|
||||
aliasResult = await api("/mail/aliases", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({...aliasForm, destination:csvDest}),
|
||||
});
|
||||
}
|
||||
setShowAliasModal(false);
|
||||
showMutationToast(Array.isArray(aliasResult) ? aliasResult[0] : aliasResult);
|
||||
load();
|
||||
} catch(e) { setModalErr(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const deleteAlias = async () => {
|
||||
if (!confirmDelAlias) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Delete all rows sharing same source+domain
|
||||
const group = getAliasGroup(confirmDelAlias.source, confirmDelAlias.domain);
|
||||
const delResults = await Promise.all(group.map(a => api(`/mail/aliases/${a.id}`, {method:"DELETE"})));
|
||||
setConfirmDelAlias(null);
|
||||
showMutationToast(delResults[0]);
|
||||
load();
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── Filtered data ─────────────────────────────────────────────────────
|
||||
// Accounts: domain required — don't show all
|
||||
const filteredAccounts = selDomain
|
||||
? accounts.filter(a =>
|
||||
a.domain === selDomain &&
|
||||
(!accSearch || `${a.username}@${a.domain}`.includes(accSearch.toLowerCase()))
|
||||
)
|
||||
: [];
|
||||
|
||||
// Aliases: group by source+domain for display (show one row per source)
|
||||
const aliasGroups = (() => {
|
||||
const groups = {};
|
||||
for (const a of aliases) {
|
||||
if (selDomain && a.domain !== selDomain) continue;
|
||||
const key = `${a.source}@${a.domain}`;
|
||||
if (!groups[key]) groups[key] = { ...a, destinations: [] };
|
||||
groups[key].destinations.push(a.destination);
|
||||
}
|
||||
return Object.values(groups).filter(g =>
|
||||
!aliasSearch ||
|
||||
g.source.includes(aliasSearch.toLowerCase()) ||
|
||||
g.domain.includes(aliasSearch.toLowerCase()) ||
|
||||
g.destinations.some(d => d.includes(aliasSearch.toLowerCase()))
|
||||
);
|
||||
})();
|
||||
|
||||
const DomainSelect = () => (
|
||||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:12}}>
|
||||
<label style={{fontSize:12,color:"var(--text-2)",whiteSpace:"nowrap"}}>Domaine</label>
|
||||
<select className="form-select" style={{maxWidth:280}}
|
||||
value={selDomain || ""} onChange={e=>setSelDomain(e.target.value||null)}>
|
||||
<option value="">— Sélectionner un domaine —</option>
|
||||
{domains.map(d=><option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
{selDomain && (
|
||||
<span style={{fontSize:11,color:"var(--text-3)"}}>
|
||||
{accounts.filter(a=>a.domain===selDomain).length} compte(s) ·{" "}
|
||||
{aliases.filter(a=>a.domain===selDomain).length} alias
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">◉</span> Mail</div>
|
||||
<div className="page-sub">Gestion des comptes et des alias</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">⚠ {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}>✕</button></div>}
|
||||
<SyncToast msg={syncMsg} success={syncSuccess} onClose={()=>setSyncMsg(null)} />
|
||||
|
||||
<PendingBanner pending={pending} onRetry={retryPending} onDismiss={dismissPending} isGlobalAdmin={isGlobalAdmin} />
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{display:"flex",gap:12,marginBottom:16,flexWrap:"wrap"}}>
|
||||
{[
|
||||
{ label:"Comptes", value:accounts.length, color:"var(--accent)" },
|
||||
{ label:"Actifs", value:accounts.filter(a=>a.active).length, color:"var(--green)" },
|
||||
{ label:"Alias", value:aliasGroups.length || aliases.length, color:"var(--yellow)" },
|
||||
{ label:"Domaines", value:domains.length, color:"var(--text-2)" },
|
||||
].map(s=>(
|
||||
<div key={s.label} className="card" style={{flex:"1 1 110px",minWidth:110,marginBottom:0}}>
|
||||
<div className="card-body" style={{padding:"12px 16px"}}>
|
||||
<div style={{fontSize:22,fontWeight:700,color:s.color,fontFamily:"var(--font-display)"}}>{s.value}</div>
|
||||
<div style={{fontSize:10,color:"var(--text-2)",textTransform:"uppercase",letterSpacing:"0.08em",marginTop:2}}>{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{display:"flex",gap:0,marginBottom:12,borderBottom:"1px solid var(--border)"}}>
|
||||
{[{id:"accounts",label:"◉ Comptes"},{id:"aliases",label:"⇄ Alias"}].map(t=>(
|
||||
<button key={t.id} onClick={()=>setTab(t.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
borderRadius:"4px 4px 0 0",
|
||||
borderBottom:tab===t.id?"2px solid var(--accent)":"2px solid transparent",
|
||||
color:tab===t.id?"var(--accent)":"var(--text-2)",
|
||||
fontWeight:tab===t.id?600:400,
|
||||
padding:"8px 18px", marginBottom:-1,
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div style={{flex:1}}/>
|
||||
{isGlobalAdmin && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={syncFromServer} disabled={syncing}
|
||||
style={{alignSelf:"center",marginBottom:4}}
|
||||
title="Synchroniser depuis MariaDB (admin global uniquement)">
|
||||
{syncing?<><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> Sync…</>:"⟳ Sync MariaDB"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shared domain selector */}
|
||||
<DomainSelect />
|
||||
|
||||
{/* ── Accounts tab ─────────────────────────────────────────────────── */}
|
||||
{tab === "accounts" && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">
|
||||
Comptes {selDomain ? `— ${selDomain}` : ""} ({filteredAccounts.length})
|
||||
</div>
|
||||
{isAdmin && selDomain && (
|
||||
<button className="btn btn-primary btn-sm" onClick={openNewAcc}>+ Nouveau compte</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{!selDomain ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">◉</div>
|
||||
<div className="empty-text">Sélectionnez un domaine ci-dessus</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="toolbar" style={{marginBottom:12}}>
|
||||
<input className="search-box" placeholder="Rechercher…" value={accSearch}
|
||||
onChange={e=>setAccSearch(e.target.value)} />
|
||||
</div>
|
||||
{loading ? <div className="loading"><div className="spinner"/> Chargement…</div>
|
||||
: filteredAccounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">◉</div>
|
||||
<div className="empty-text">Aucun compte pour ce domaine</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Adresse</th><th>Quota</th><th>Statut</th>
|
||||
{isAdmin && <th></th>}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{filteredAccounts.map(acc=>(
|
||||
<tr key={acc.id}>
|
||||
<td className="cell-mono" style={{fontWeight:500}}>
|
||||
{acc.username}@{acc.domain}
|
||||
</td>
|
||||
<td><QuotaBar used={acc.used||0} total={acc.quota}/></td>
|
||||
<td>
|
||||
{acc.active
|
||||
? <span className="badge badge-active">✓ Actif</span>
|
||||
: <span className="badge badge-inactive">✗ Inactif</span>}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td><div className="cell-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={()=>openEditAcc(acc)}>✎</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}}
|
||||
onClick={()=>setConfirmDelAcc(acc)}>✕</button>
|
||||
</div></td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Aliases tab ──────────────────────────────────────────────────── */}
|
||||
{tab === "aliases" && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">
|
||||
Alias {selDomain ? `— ${selDomain}` : ""} ({aliasGroups.length})
|
||||
</div>
|
||||
{isAdmin && selDomain && (
|
||||
<button className="btn btn-primary btn-sm" onClick={openNewAlias}>+ Nouvel alias</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{!selDomain ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⇄</div>
|
||||
<div className="empty-text">Sélectionnez un domaine ci-dessus</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="toolbar" style={{marginBottom:12}}>
|
||||
<input className="search-box" placeholder="Rechercher…" value={aliasSearch}
|
||||
onChange={e=>setAliasSearch(e.target.value)} />
|
||||
</div>
|
||||
{loading ? <div className="loading"><div className="spinner"/> Chargement…</div>
|
||||
: aliasGroups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⇄</div>
|
||||
<div className="empty-text">Aucun alias pour ce domaine</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Source</th><th></th><th>Destination(s)</th>
|
||||
{isAdmin && <th></th>}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{aliasGroups.map(alias=>(
|
||||
<tr key={`${alias.source}@${alias.domain}`}>
|
||||
<td className="cell-mono" style={{color:"var(--accent)",fontWeight:500,whiteSpace:"nowrap"}}>
|
||||
{alias.source}@{alias.domain}
|
||||
</td>
|
||||
<td style={{color:"var(--text-3)",textAlign:"center",padding:"0 4px"}}>→</td>
|
||||
<td>
|
||||
<div style={{display:"flex",flexWrap:"wrap",gap:4}}>
|
||||
{alias.destinations.map(d=>(
|
||||
<span key={d} style={{
|
||||
display:"inline-block",fontFamily:"var(--font-mono)",fontSize:11,
|
||||
padding:"1px 6px",background:"var(--bg-0)",
|
||||
border:"1px solid var(--border)",borderRadius:3,
|
||||
}}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td><div className="cell-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={()=>openEditAlias(alias)}>✎</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}}
|
||||
onClick={()=>setConfirmDelAlias(alias)}>✕</button>
|
||||
</div></td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Account modal ─────────────────────────────────────────────────── */}
|
||||
{showAccModal && (
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowAccModal(false)}>
|
||||
<div className="modal" style={{maxWidth:460}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{editAcc?"Modifier le compte":"Nouveau compte"}</div>
|
||||
<button className="modal-close" onClick={()=>setShowAccModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{modalErr && <div className="alert alert-error">⚠ {modalErr}</div>}
|
||||
{!editAcc ? (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Identifiant</label>
|
||||
<input className="form-input" placeholder="utilisateur" value={accForm.username}
|
||||
onChange={e=>setAccForm({...accForm,username:e.target.value})} autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Domaine</label>
|
||||
<select className="form-select" value={accForm.domain}
|
||||
onChange={e=>setAccForm({...accForm,domain:e.target.value})}>
|
||||
<option value="">— Choisir —</option>
|
||||
{domains.map(d=><option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Adresse</label>
|
||||
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
|
||||
{editAcc.username}@{editAcc.domain}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
Mot de passe{editAcc&&<span style={{color:"var(--text-3)",fontWeight:400,marginLeft:6}}>(vide = inchangé)</span>}
|
||||
</label>
|
||||
<input className="form-input" type="password"
|
||||
placeholder={editAcc?"••••••••":"Mot de passe"}
|
||||
value={accForm.password}
|
||||
onChange={e=>setAccForm({...accForm,password:e.target.value})} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Quota (Mo)</label>
|
||||
<input className="form-input" type="number" min="1" value={accForm.quota}
|
||||
onChange={e=>setAccForm({...accForm,quota:+e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group" style={{justifyContent:"flex-end",paddingTop:22}}>
|
||||
<label style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:13}}>
|
||||
<input type="checkbox" checked={accForm.active}
|
||||
onChange={e=>setAccForm({...accForm,active:e.target.checked})} />
|
||||
Compte actif
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>setShowAccModal(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={saveAcc} disabled={saving}>
|
||||
{saving?"Sauvegarde…":editAcc?"Enregistrer":"Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Alias modal ───────────────────────────────────────────────────── */}
|
||||
{showAliasModal && (
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowAliasModal(false)}>
|
||||
<div className="modal" style={{maxWidth:460}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{editAlias?"Modifier l'alias":"Nouvel alias"}</div>
|
||||
<button className="modal-close" onClick={()=>setShowAliasModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{modalErr && <div className="alert alert-error">⚠ {modalErr}</div>}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Source (partie locale)</label>
|
||||
{editAlias ? (
|
||||
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
|
||||
{editAlias.source}
|
||||
</div>
|
||||
) : (
|
||||
<input className="form-input" placeholder="contact"
|
||||
value={aliasForm.source}
|
||||
onChange={e=>setAliasForm({...aliasForm,source:e.target.value.split("@")[0]})}
|
||||
autoFocus />
|
||||
)}
|
||||
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>Partie avant le @</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Domaine</label>
|
||||
{editAlias ? (
|
||||
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
|
||||
{editAlias.domain}
|
||||
</div>
|
||||
) : (
|
||||
<select className="form-select" value={aliasForm.domain}
|
||||
onChange={e=>setAliasForm({...aliasForm,domain:e.target.value})}>
|
||||
<option value="">— Choisir —</option>
|
||||
{domains.map(d=><option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
Destination(s)
|
||||
<span style={{color:"var(--text-3)",fontWeight:400,marginLeft:6}}>
|
||||
— une adresse par ligne
|
||||
</span>
|
||||
</label>
|
||||
<textarea className="form-input" rows={4}
|
||||
placeholder={"user@example.com\nalias2@other.com"}
|
||||
value={aliasForm.destination}
|
||||
onChange={e=>setAliasForm({...aliasForm,destination:e.target.value})}
|
||||
style={{resize:"vertical",fontFamily:"var(--font-mono)",fontSize:12}}
|
||||
/>
|
||||
</div>
|
||||
{(aliasForm.source||editAlias?.source) && (aliasForm.domain||editAlias?.domain) && aliasForm.destination && (
|
||||
<div style={{padding:"8px 12px",background:"var(--bg-0)",borderRadius:6,fontSize:11,color:"var(--text-2)"}}>
|
||||
<span style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>
|
||||
{aliasForm.source||editAlias?.source}@{aliasForm.domain||editAlias?.domain}
|
||||
</span>
|
||||
{" "}→{" "}
|
||||
<span style={{fontFamily:"var(--font-mono)"}}>
|
||||
{aliasForm.destination.split(/[\n,]/).map(d=>d.trim()).filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>setShowAliasModal(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={saveAlias} disabled={saving}>
|
||||
{saving?"Sauvegarde…":editAlias?"Enregistrer":"Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Delete account confirmation ───────────────────────────────────── */}
|
||||
{confirmDelAcc && (
|
||||
<div className="modal-overlay" onClick={()=>setConfirmDelAcc(null)}>
|
||||
<div className="modal" style={{maxWidth:400}} onClick={e=>e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer le compte</div>
|
||||
<button className="modal-close" onClick={()=>setConfirmDelAcc(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7}}>
|
||||
Supprimer{" "}
|
||||
<strong style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>
|
||||
{confirmDelAcc.username}@{confirmDelAcc.domain}
|
||||
</strong> ?
|
||||
</p>
|
||||
<div style={{marginTop:8,padding:"6px 10px",background:"var(--red-dim)",
|
||||
border:"1px solid rgba(255,74,106,0.2)",borderRadius:4,fontSize:11,color:"var(--red)"}}>
|
||||
⚠ Les emails stockés sur le serveur ne seront pas supprimés.
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>setConfirmDelAcc(null)}>Annuler</button>
|
||||
<button className="btn btn-danger" onClick={deleteAcc} disabled={saving}>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Delete alias confirmation ─────────────────────────────────────── */}
|
||||
{confirmDelAlias && (
|
||||
<div className="modal-overlay" onClick={()=>setConfirmDelAlias(null)}>
|
||||
<div className="modal" style={{maxWidth:400}} onClick={e=>e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer l'alias</div>
|
||||
<button className="modal-close" onClick={()=>setConfirmDelAlias(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7}}>
|
||||
Supprimer l'alias{" "}
|
||||
<strong style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>
|
||||
{confirmDelAlias.source}@{confirmDelAlias.domain}
|
||||
</strong>
|
||||
{confirmDelAlias.destinations?.length > 1
|
||||
? ` (${confirmDelAlias.destinations.length} destinations)`
|
||||
: ` → ${confirmDelAlias.destinations?.[0] || confirmDelAlias.destination}`
|
||||
} ?
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={()=>setConfirmDelAlias(null)}>Annuler</button>
|
||||
<button className="btn btn-danger" onClick={deleteAlias} disabled={saving}>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const EMPTY_LIST = { name: "", domain: "", description: "", moderator: "" };
|
||||
|
||||
export default function MailingLists({ apiBase, token, onUnauthorized }) {
|
||||
const [lists, setLists] = useState([]);
|
||||
const [domains, setDomains] = useState([]);
|
||||
const [selectedList, setSelectedList] = useState(null);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editList, setEditList] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_LIST);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newMember, setNewMember] = useState("");
|
||||
const [addingMember, setAddingMember] = useState(false);
|
||||
|
||||
const api = useCallback(async (path, options = {}) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, ...options,
|
||||
});
|
||||
if (r.status === 401) {
|
||||
onUnauthorized?.();
|
||||
throw new Error("Session expirée — veuillez vous reconnecter.");
|
||||
}
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({ detail: r.statusText }));
|
||||
throw new Error(err.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
const loadLists = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([api("/mail/lists"), api("/mail/domains")])
|
||||
.then(([ls, doms]) => { setLists(ls); setDomains(doms); })
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => { loadLists(); }, [loadLists]);
|
||||
|
||||
const loadMembers = useCallback((list) => {
|
||||
setLoadingMembers(true);
|
||||
api(`/mail/lists/${list.id}/members`)
|
||||
.then(setMembers)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoadingMembers(false));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedList) loadMembers(selectedList);
|
||||
}, [selectedList, loadMembers]);
|
||||
|
||||
const filtered = lists.filter((l) =>
|
||||
!search || l.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.domain.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const openNew = () => {
|
||||
setForm({ ...EMPTY_LIST, domain: domains[0] || "" });
|
||||
setEditList(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (list) => {
|
||||
setForm({ name: list.name, domain: list.domain, description: list.description || "", moderator: list.moderator || "" });
|
||||
setEditList(list);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const saveList = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editList) {
|
||||
await api(`/mail/lists/${editList.id}`, { method: "PUT", body: JSON.stringify(form) });
|
||||
} else {
|
||||
await api("/mail/lists", { method: "POST", body: JSON.stringify(form) });
|
||||
}
|
||||
loadLists();
|
||||
setShowModal(false);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteList = async (list) => {
|
||||
if (!confirm(`Delete list ${list.name}@${list.domain}?`)) return;
|
||||
try {
|
||||
await api(`/mail/lists/${list.id}`, { method: "DELETE" });
|
||||
if (selectedList?.id === list.id) setSelectedList(null);
|
||||
loadLists();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const addMember = async () => {
|
||||
if (!newMember || !selectedList) return;
|
||||
setAddingMember(true);
|
||||
try {
|
||||
await api(`/mail/lists/${selectedList.id}/members`, {
|
||||
method: "POST", body: JSON.stringify({ email: newMember }),
|
||||
});
|
||||
setNewMember("");
|
||||
loadMembers(selectedList);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setAddingMember(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (email) => {
|
||||
try {
|
||||
await api(`/mail/lists/${selectedList.id}/members/${encodeURIComponent(email)}`, { method: "DELETE" });
|
||||
setMembers((prev) => prev.filter((m) => m.email !== email));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">◎</span> Mailing Lists</div>
|
||||
<div className="page-sub">Manage distribution lists and subscribers</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">⚠ {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={() => setError(null)}>✕</button></div>}
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
||||
{/* Left: Lists */}
|
||||
<div className="card" style={{marginBottom:0}}>
|
||||
<div className="card-header">
|
||||
<div className="card-title">◎ Lists ({lists.length})</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={openNew}>+ New List</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<input className="search-box" placeholder="Search lists…" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)} style={{marginBottom:12}} />
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner" /> Loading…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">◎</div>
|
||||
<div className="empty-text">No lists yet</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{display:"flex",flexDirection:"column",gap:6}}>
|
||||
{filtered.map((list) => (
|
||||
<div key={list.id}
|
||||
className={`list-row ${selectedList?.id === list.id ? "list-row--active" : ""}`}
|
||||
style={{
|
||||
padding:"10px 12px",
|
||||
borderRadius:6,
|
||||
border:`1px solid ${selectedList?.id === list.id ? "rgba(0,212,255,0.25)" : "var(--border)"}`,
|
||||
background: selectedList?.id === list.id ? "var(--accent-dim)" : "var(--bg-2)",
|
||||
cursor:"pointer",
|
||||
transition:"all 0.15s",
|
||||
}}
|
||||
onClick={() => setSelectedList(list)}
|
||||
>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"start"}}>
|
||||
<div>
|
||||
<div style={{fontFamily:"var(--font-mono)",fontSize:12,color:"var(--text-0)"}}>
|
||||
<span style={{color: selectedList?.id === list.id ? "var(--accent)" : "var(--text-0)"}}>{list.name}</span>
|
||||
<span style={{color:"var(--text-3)"}}>@</span>
|
||||
<span style={{color:"var(--text-1)"}}>{list.domain}</span>
|
||||
</div>
|
||||
{list.description && (
|
||||
<div style={{fontSize:11,color:"var(--text-2)",marginTop:3}}>{list.description}</div>
|
||||
)}
|
||||
<div style={{fontSize:10,color:"var(--text-3)",marginTop:4}}>
|
||||
{list.member_count || 0} member{list.member_count !== 1 ? "s" : ""}
|
||||
{list.moderator && ` · mod: ${list.moderator}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:"flex",gap:3}}>
|
||||
<button className="btn btn-ghost btn-sm" onClick={(e) => {e.stopPropagation(); openEdit(list);}} title="Edit">✎</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={(e) => {e.stopPropagation(); deleteList(list);}} title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Members */}
|
||||
<div className="card" style={{marginBottom:0}}>
|
||||
<div className="card-header">
|
||||
<div className="card-title">
|
||||
{selectedList
|
||||
? <><span style={{color:"var(--accent)"}}>◉</span> {selectedList.name}@{selectedList.domain}</>
|
||||
: "Members"}
|
||||
</div>
|
||||
{selectedList && <span style={{fontSize:11,color:"var(--text-2)"}}>{members.length} subscriber{members.length !== 1 ? "s" : ""}</span>}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{!selectedList ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">◎</div>
|
||||
<div className="empty-text">Select a list to manage its members</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{display:"flex",gap:8,marginBottom:12}}>
|
||||
<input className="form-input" placeholder="new@subscriber.com" value={newMember}
|
||||
onChange={(e) => setNewMember(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addMember()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={addMember} disabled={addingMember || !newMember}>
|
||||
{addingMember ? "…" : "+ Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingMembers ? (
|
||||
<div className="loading"><div className="spinner" /> Loading members…</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="empty-state" style={{padding:"24px"}}>
|
||||
<div className="empty-text">No members yet</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:380,overflowY:"auto"}}>
|
||||
{members.map((m) => (
|
||||
<div key={m.email} style={{
|
||||
display:"flex",justifyContent:"space-between",alignItems:"center",
|
||||
padding:"7px 10px",borderRadius:4,background:"var(--bg-2)",
|
||||
border:"1px solid var(--border)",
|
||||
}}>
|
||||
<span style={{fontFamily:"var(--font-mono)",fontSize:12,color:"var(--text-1)"}}>{m.email}</span>
|
||||
<div style={{display:"flex",alignItems:"center",gap:8}}>
|
||||
{m.subscribed_at && <span style={{fontSize:10,color:"var(--text-3)"}}>{new Date(m.subscribed_at).toLocaleDateString()}</span>}
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)",padding:"2px 6px"}}
|
||||
onClick={() => removeMember(m.email)} title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{editList ? "Edit List" : "New Mailing List"}</div>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">List Name</label>
|
||||
<input className="form-input" placeholder="newsletter" value={form.name}
|
||||
onChange={(e) => setForm({...form, name: e.target.value})} disabled={!!editList} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Domain</label>
|
||||
<select className="form-select" value={form.domain}
|
||||
onChange={(e) => setForm({...form, domain: e.target.value})} disabled={!!editList}>
|
||||
{domains.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description</label>
|
||||
<input className="form-input" placeholder="Company newsletter" value={form.description}
|
||||
onChange={(e) => setForm({...form, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Moderator email</label>
|
||||
<input className="form-input" placeholder="admin@example.com" value={form.moderator}
|
||||
onChange={(e) => setForm({...form, moderator: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={saveList} disabled={saving}>
|
||||
{saving ? "Saving…" : editList ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const SERVICE_COLORS = { dns:"var(--accent)", mail:"var(--yellow)", "":"var(--text-2)" };
|
||||
|
||||
const STATUS_CFG = {
|
||||
pending: { color:"var(--yellow)", bg:"rgba(255,200,0,0.12)", icon:"⏳", label:"En attente" },
|
||||
retrying: { color:"var(--accent)", bg:"rgba(0,212,255,0.10)", icon:"↺", label:"En cours" },
|
||||
failed: { color:"var(--red)", bg:"rgba(255,74,106,0.12)",icon:"✕", label:"Échec" },
|
||||
done: { color:"var(--green)", bg:"rgba(0,200,100,0.10)", icon:"✓", label:"Appliqué" },
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS = {
|
||||
startup: "Démarrage",
|
||||
poll: "Polling auto",
|
||||
manual: "Manuel",
|
||||
agent_reconnect: "Reconnexion agent",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const c = STATUS_CFG[status] || STATUS_CFG.pending;
|
||||
return <span style={{display:"inline-flex",alignItems:"center",gap:4,padding:"2px 8px",
|
||||
borderRadius:3,fontSize:10,fontWeight:600,background:c.bg,color:c.color,
|
||||
border:`1px solid ${c.color}44`}}>{c.icon} {c.label}</span>;
|
||||
}
|
||||
|
||||
function ServiceBadge({ service }) {
|
||||
const col = SERVICE_COLORS[service] || "var(--text-2)";
|
||||
return <span style={{display:"inline-block",padding:"1px 7px",borderRadius:3,
|
||||
fontSize:10,fontWeight:600,textTransform:"uppercase",
|
||||
background:col+"22",color:col,border:`1px solid ${col}44`}}>{service}</span>;
|
||||
}
|
||||
|
||||
function TriggerBadge({ trigger }) {
|
||||
return <span style={{fontSize:11,color:"var(--text-2)",
|
||||
background:"var(--bg-2)",padding:"1px 6px",borderRadius:3}}>
|
||||
{TRIGGER_LABELS[trigger] || trigger}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function fmtDate(ts) {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts+"Z").toLocaleString("fr-FR",{
|
||||
day:"2-digit",month:"2-digit",year:"numeric",
|
||||
hour:"2-digit",minute:"2-digit",second:"2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return "—";
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms/1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function fmtAction(action) {
|
||||
const m = { apply_zone:"Appliquer zone", delete_zone:"Supprimer zone",
|
||||
apply_domain:"Appliquer domaine mail", delete_domain:"Supprimer domaine mail",
|
||||
get_state:"Lecture état" };
|
||||
return m[action] || action;
|
||||
}
|
||||
|
||||
function getTarget(payload) {
|
||||
try { const p=JSON.parse(payload); return p.zone||p.domain||"—"; } catch { return "—"; }
|
||||
}
|
||||
|
||||
// ── Queue tab ────────────────────────────────────────────────────────────
|
||||
function QueueTab({ apiBase, token, onUnauthorized }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [expanded,setExpanded]= useState(null);
|
||||
|
||||
const api = useCallback(async (path, opts={}) => {
|
||||
const r = await fetch(`${apiBase}${path}`,{
|
||||
headers:{"Content-Type":"application/json","Authorization":`Bearer ${token}`},...opts});
|
||||
if (r.status===401){onUnauthorized?.();throw new Error("Session expirée.");}
|
||||
if (r.status===204) return null;
|
||||
if (!r.ok){const e=await r.json().catch(()=>({detail:r.statusText}));throw new Error(e.detail||r.statusText);}
|
||||
return r.json();
|
||||
},[apiBase,token]);
|
||||
|
||||
const load = useCallback(()=>{
|
||||
setLoading(true);
|
||||
api("/mail/pending").then(d=>setItems(d||[])).catch(e=>setError(e.message)).finally(()=>setLoading(false));
|
||||
},[api]);
|
||||
|
||||
useEffect(()=>{load();},[load]);
|
||||
useEffect(()=>{const t=setInterval(load,10000);return()=>clearInterval(t);},[load]);
|
||||
|
||||
const retry = async id=>{try{await api(`/mail/pending/${id}/retry`,{method:"POST"});load();}catch(e){setError(e.message);}};
|
||||
const dismiss = async id=>{try{await api(`/mail/pending/${id}`,{method:"DELETE"});load();}catch(e){setError(e.message);}};
|
||||
|
||||
const filtered = items.filter(i=>{
|
||||
if(filter==="dns") return i.service==="dns";
|
||||
if(filter==="mail") return i.service==="mail";
|
||||
if(filter==="pending") return i.status==="pending"||i.status==="retrying";
|
||||
if(filter==="failed") return i.status==="failed";
|
||||
return true;
|
||||
});
|
||||
|
||||
const counts = { all:items.length, dns:items.filter(i=>i.service==="dns").length,
|
||||
mail:items.filter(i=>i.service==="mail").length,
|
||||
pending:items.filter(i=>i.status==="pending"||i.status==="retrying").length,
|
||||
failed:items.filter(i=>i.status==="failed").length,
|
||||
done:items.filter(i=>i.status==="done").length };
|
||||
|
||||
return <>
|
||||
{error && <div className="alert alert-error" style={{marginBottom:12}}>⚠ {error}
|
||||
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}>✕</button></div>}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
|
||||
{[{k:"pending",l:"En attente",c:"var(--yellow)"},{k:"failed",l:"En échec",c:"var(--red)"},
|
||||
{k:"done",l:"Appliquées",c:"var(--green)"},{k:"dns",l:"DNS",c:"var(--accent)"},
|
||||
{k:"mail",l:"Mail",c:"var(--yellow)"}].map(s=>(
|
||||
<div key={s.k} className="card" style={{flex:"1 1 90px",minWidth:90,marginBottom:0,cursor:"pointer",
|
||||
border:filter===s.k?`1px solid ${s.c}55`:"1px solid var(--border)"}}
|
||||
onClick={()=>setFilter(filter===s.k?"all":s.k)}>
|
||||
<div className="card-body" style={{padding:"10px 14px"}}>
|
||||
<div style={{fontSize:22,fontWeight:700,color:s.c,fontFamily:"var(--font-display)"}}>{counts[s.k]}</div>
|
||||
<div style={{fontSize:10,color:"var(--text-2)",textTransform:"uppercase",letterSpacing:"0.08em",marginTop:2}}>{s.l}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions bar */}
|
||||
<div style={{display:"flex",gap:8,marginBottom:12,alignItems:"center"}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={load} disabled={loading}>
|
||||
{loading?<><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> …</>:"⟳ Actualiser"}
|
||||
</button>
|
||||
{counts.pending>0 && <button className="btn btn-primary btn-sm" onClick={async()=>{
|
||||
for(const i of filtered.filter(x=>x.status!=="done")) await api(`/mail/pending/${i.id}/retry`,{method:"POST"}).catch(()=>{});
|
||||
load();
|
||||
}}>↺ Relancer tout ({counts.pending})</button>}
|
||||
{counts.done>0 && <button className="btn btn-ghost btn-sm" style={{color:"var(--text-3)"}} onClick={async()=>{
|
||||
for(const i of items.filter(x=>x.status==="done")) await api(`/mail/pending/${i.id}`,{method:"DELETE"}).catch(()=>{});
|
||||
load();
|
||||
}}>Vider les appliquées ({counts.done})</button>}
|
||||
<div style={{flex:1}}/>
|
||||
<span style={{fontSize:11,color:"var(--text-3)"}}>Actualisation auto · 10s</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">{filtered.length} action{filtered.length!==1?"s":""}
|
||||
{filter!=="all"&&<span style={{color:"var(--text-3)",fontWeight:400,marginLeft:6}}>— {filter}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body" style={{padding:0}}>
|
||||
{loading&&!items.length ? <div className="loading" style={{padding:24}}><div className="spinner"/> Chargement…</div>
|
||||
: filtered.length===0 ? <div className="empty-state" style={{padding:40}}>
|
||||
<div className="empty-icon">✓</div>
|
||||
<div className="empty-text">{items.length===0?"Aucune action en attente — agents synchronisés":"Aucune action dans ce filtre"}</div>
|
||||
</div>
|
||||
: <div className="table-wrap"><table>
|
||||
<thead><tr><th>Statut</th><th>Service</th><th>Action</th><th>Cible</th><th>Créée le</th><th>Mise à jour</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{filtered.map(item=>{
|
||||
const isExp = expanded===item.id;
|
||||
return <>
|
||||
<tr key={item.id} onClick={()=>setExpanded(isExp?null:item.id)}
|
||||
style={{cursor:"pointer",background:isExp?"var(--bg-2)":"transparent"}}>
|
||||
<td><StatusBadge status={item.status}/></td>
|
||||
<td><ServiceBadge service={item.service}/></td>
|
||||
<td style={{fontSize:12,color:"var(--text-1)"}}>{fmtAction(item.action)}</td>
|
||||
<td className="cell-mono" style={{fontSize:11,color:"var(--accent)"}}>{getTarget(item.payload)}</td>
|
||||
<td className="cell-dim" style={{fontSize:11,whiteSpace:"nowrap"}}>{fmtDate(item.created_at)}</td>
|
||||
<td className="cell-dim" style={{fontSize:11,whiteSpace:"nowrap"}}>{fmtDate(item.updated_at)}</td>
|
||||
<td><div className="cell-actions">
|
||||
{item.status!=="done"&&<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--accent)"}}
|
||||
onClick={e=>{e.stopPropagation();retry(item.id)}} title="Réessayer">↺</button>}
|
||||
<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--text-3)"}}
|
||||
onClick={e=>{e.stopPropagation();dismiss(item.id)}} title="Supprimer">✕</button>
|
||||
</div></td>
|
||||
</tr>
|
||||
{isExp&&item.error&&<tr key={item.id+"-e"} style={{background:"var(--bg-0)"}}>
|
||||
<td colSpan={7} style={{padding:"6px 14px"}}>
|
||||
<div style={{fontSize:11,color:"var(--red)",fontFamily:"var(--font-mono)"}}>⚠ {item.error}</div>
|
||||
</td>
|
||||
</tr>}
|
||||
</>;
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>}
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
// ── History tab ──────────────────────────────────────────────────────────
|
||||
function HistoryTab({ apiBase, token, onUnauthorized }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filterSvc, setFilterSvc] = useState("");
|
||||
const [filterTrig, setFilterTrig] = useState("");
|
||||
const PAGE = 50;
|
||||
|
||||
const api = useCallback(async (path)=>{
|
||||
const r = await fetch(`${apiBase}${path}`,{headers:{"Authorization":`Bearer ${token}`}});
|
||||
if(r.status===401){onUnauthorized?.();throw new Error("Session expirée.");}
|
||||
if(!r.ok){const e=await r.json().catch(()=>({detail:r.statusText}));throw new Error(e.detail||r.statusText);}
|
||||
return r.json();
|
||||
},[apiBase,token]);
|
||||
|
||||
const load = useCallback((p=0)=>{
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({limit:PAGE,offset:p*PAGE});
|
||||
if(filterSvc) params.set("service",filterSvc);
|
||||
if(filterTrig) params.set("trigger",filterTrig);
|
||||
api(`/sync/history?${params}`)
|
||||
.then(d=>{setEntries(d.entries);setTotal(d.total);setPage(p);})
|
||||
.catch(e=>setError(e.message))
|
||||
.finally(()=>setLoading(false));
|
||||
},[api,filterSvc,filterTrig]);
|
||||
|
||||
useEffect(()=>{load(0);},[load]);
|
||||
|
||||
const totalPages = Math.ceil(total/PAGE);
|
||||
|
||||
return <>
|
||||
{error && <div className="alert alert-error" style={{marginBottom:12}}>⚠ {error}
|
||||
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}>✕</button></div>}
|
||||
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div className="card-body" style={{padding:"12px 16px"}}>
|
||||
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end"}}>
|
||||
<div className="form-group" style={{marginBottom:0,minWidth:130}}>
|
||||
<label className="form-label" style={{marginBottom:4}}>Service</label>
|
||||
<select className="form-select" value={filterSvc} onChange={e=>{setFilterSvc(e.target.value);setPage(0);}}>
|
||||
<option value="">Tous</option>
|
||||
<option value="dns">DNS</option>
|
||||
<option value="mail">Mail</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{marginBottom:0,minWidth:160}}>
|
||||
<label className="form-label" style={{marginBottom:4}}>Déclencheur</label>
|
||||
<select className="form-select" value={filterTrig} onChange={e=>{setFilterTrig(e.target.value);setPage(0);}}>
|
||||
<option value="">Tous</option>
|
||||
<option value="startup">Démarrage</option>
|
||||
<option value="poll">Polling auto</option>
|
||||
<option value="manual">Manuel</option>
|
||||
<option value="agent_reconnect">Reconnexion agent</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>load(0)} style={{alignSelf:"flex-end",marginBottom:0}}>
|
||||
⟳ Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">{total} synchronisation{total!==1?"s":""}</div>
|
||||
{totalPages>1&&<div style={{display:"flex",gap:6,alignItems:"center"}}>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page===0} onClick={()=>load(page-1)}>‹ Préc.</button>
|
||||
<span style={{fontSize:12,color:"var(--text-2)"}}>{page+1} / {totalPages}</span>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page>=totalPages-1} onClick={()=>load(page+1)}>Suiv. ›</button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="card-body" style={{padding:0}}>
|
||||
{loading&&!entries.length ? <div className="loading" style={{padding:24}}><div className="spinner"/> Chargement…</div>
|
||||
: entries.length===0 ? <div className="empty-state" style={{padding:40}}>
|
||||
<div className="empty-icon">⟳</div>
|
||||
<div className="empty-text">Aucune synchronisation enregistrée</div>
|
||||
</div>
|
||||
: <div className="table-wrap"><table>
|
||||
<thead><tr>
|
||||
<th style={{width:16}}></th>
|
||||
<th>Date</th><th>Service</th><th>Déclencheur</th>
|
||||
<th>Cibles</th><th>Éléments</th><th>Durée</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{entries.map(e=>(
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
<span style={{display:"inline-block",width:8,height:8,borderRadius:"50%",
|
||||
background:e.success?"var(--green)":"var(--red)"}}
|
||||
title={e.success?"Succès":"Échec : "+(e.error||"")}/>
|
||||
</td>
|
||||
<td className="cell-dim" style={{fontSize:11,whiteSpace:"nowrap"}}>{fmtDate(e.ts)}</td>
|
||||
<td><ServiceBadge service={e.service}/></td>
|
||||
<td><TriggerBadge trigger={e.trigger}/></td>
|
||||
<td style={{fontSize:12}}>{e.zones ?? "—"}</td>
|
||||
<td style={{fontSize:12}}>{e.records ?? "—"}</td>
|
||||
<td className="cell-dim" style={{fontSize:11}}>{fmtDuration(e.duration_ms)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>}
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────
|
||||
export default function SyncDashboard({ apiBase, token, onUnauthorized }) {
|
||||
const [tab, setTab] = useState("queue");
|
||||
|
||||
return <>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">⟳</span> Synchronisations</div>
|
||||
<div className="page-sub">File d'attente et historique des synchronisations agents</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{display:"flex",gap:0,marginBottom:16,borderBottom:"1px solid var(--border)"}}>
|
||||
{[{id:"queue",label:"⏳ File d'attente"},{id:"history",label:"📋 Historique"}].map(t=>(
|
||||
<button key={t.id} onClick={()=>setTab(t.id)} className="btn btn-ghost"
|
||||
style={{borderRadius:"4px 4px 0 0",marginBottom:-1,padding:"8px 18px",
|
||||
borderBottom:tab===t.id?"2px solid var(--accent)":"2px solid transparent",
|
||||
color:tab===t.id?"var(--accent)":"var(--text-2)",
|
||||
fontWeight:tab===t.id?600:400}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab==="queue" && <QueueTab apiBase={apiBase} token={token} onUnauthorized={onUnauthorized}/>}
|
||||
{tab==="history" && <HistoryTab apiBase={apiBase} token={token} onUnauthorized={onUnauthorized}/>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* SyncToast — popup notification for sync operations.
|
||||
*
|
||||
* Props:
|
||||
* msg : string | null — message to show (null = hidden)
|
||||
* success : bool — green if true, red if false
|
||||
* onClose : () => void — called when closed (auto or manual)
|
||||
*
|
||||
* Auto-closes after 5s on success. Stays open on error until manually closed.
|
||||
*/
|
||||
export default function SyncToast({ msg, success, onClose }) {
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!msg) return;
|
||||
if (success) {
|
||||
timer.current = setTimeout(() => onClose?.(), 5000);
|
||||
}
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [msg, success, onClose]);
|
||||
|
||||
if (!msg) return null;
|
||||
|
||||
const bg = success ? "var(--green)" : "var(--red)";
|
||||
const bgDim = success ? "rgba(0,200,100,0.12)" : "rgba(255,74,106,0.12)";
|
||||
const icon = success ? "✓" : "⚠";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 9999,
|
||||
minWidth: 280,
|
||||
maxWidth: 440,
|
||||
background: bgDim,
|
||||
border: `1px solid ${bg}44`,
|
||||
borderLeft: `4px solid ${bg}`,
|
||||
borderRadius: 8,
|
||||
padding: "12px 16px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
animation: "slideIn 0.2s ease",
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 16,
|
||||
color: bg,
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
}}>{icon}</span>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: success ? "var(--green)" : "var(--red)",
|
||||
fontWeight: 600,
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{success ? "Synchronisation effectuée" : "En attente de synchronisation"}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-1)",
|
||||
lineHeight: 1.5,
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{msg}
|
||||
</div>
|
||||
{success && (
|
||||
<div style={{ fontSize: 10, color: "var(--text-3)", marginTop: 4 }}>
|
||||
Fermeture automatique dans 5s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={onClose} style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-3)",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
flexShrink: 0,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export default function UserProfile({ apiBase, token, user, onUnauthorized }) {
|
||||
const [tab, setTab] = useState("password");
|
||||
|
||||
// ── Password change ───────────────────────────────────────────────────
|
||||
const [pwForm, setPwForm] = useState({ current: "", next: "", confirm: "" });
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [pwMsg, setPwMsg] = useState(null);
|
||||
const [pwErr, setPwErr] = useState(null);
|
||||
|
||||
// ── TOTP ──────────────────────────────────────────────────────────────
|
||||
const [totpState, setTotpState] = useState("idle"); // idle|setup|verifying|active|disabling
|
||||
const [totpData, setTotpData] = useState(null); // {secret, qr_b64}
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpPw, setTotpPw] = useState(""); // for disable
|
||||
const [totpMsg, setTotpMsg] = useState(null);
|
||||
const [totpErr, setTotpErr] = useState(null);
|
||||
const [totpEnabled, setTotpEnabled] = useState(user?.totp_enabled || false);
|
||||
|
||||
const api = useCallback(async (path, options = {}) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
||||
...options,
|
||||
});
|
||||
if (r.status === 401) { onUnauthorized?.(); throw new Error("Session expirée."); }
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({ detail: r.statusText }));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
// ── Password handlers ─────────────────────────────────────────────────
|
||||
const changePassword = async () => {
|
||||
setPwErr(null); setPwMsg(null);
|
||||
if (!pwForm.current) { setPwErr("Mot de passe actuel requis"); return; }
|
||||
if (pwForm.next.length < 8) { setPwErr("Le nouveau mot de passe doit faire au moins 8 caractères"); return; }
|
||||
if (pwForm.next !== pwForm.confirm) { setPwErr("Les mots de passe ne correspondent pas"); return; }
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await api("/auth/me/password", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
current_password: pwForm.current,
|
||||
new_password: pwForm.next,
|
||||
}),
|
||||
});
|
||||
setPwMsg("Mot de passe modifié avec succès.");
|
||||
setPwForm({ current: "", next: "", confirm: "" });
|
||||
} catch(e) { setPwErr(e.message); }
|
||||
finally { setPwSaving(false); }
|
||||
};
|
||||
|
||||
// ── TOTP handlers ─────────────────────────────────────────────────────
|
||||
const startTotpSetup = async () => {
|
||||
setTotpErr(null); setTotpMsg(null); setTotpCode("");
|
||||
try {
|
||||
const data = await api("/auth/totp/setup", { method: "POST" });
|
||||
if (!data?.qr_b64) throw new Error("Réponse invalide du serveur");
|
||||
setTotpData(data);
|
||||
setTotpState("verifying");
|
||||
} catch(e) { setTotpErr(e.message); }
|
||||
};
|
||||
|
||||
const verifyTotp = async () => {
|
||||
setTotpErr(null);
|
||||
try {
|
||||
const res = await api("/auth/totp/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code: totpCode }),
|
||||
});
|
||||
if (!res?.success) throw new Error(res?.message || "Échec de l'activation");
|
||||
setTotpEnabled(true);
|
||||
setTotpState("idle");
|
||||
setTotpData(null);
|
||||
setTotpCode("");
|
||||
setTotpMsg("Double authentification activée avec succès ✓ — elle sera demandée à votre prochaine connexion.");
|
||||
} catch(e) { setTotpErr(e.message || "Code invalide — vérifiez l'heure de votre appareil"); }
|
||||
};
|
||||
|
||||
const disableTotp = async () => {
|
||||
setTotpErr(null);
|
||||
try {
|
||||
await api("/auth/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: totpPw }),
|
||||
});
|
||||
setTotpEnabled(false);
|
||||
setTotpState("idle");
|
||||
setTotpPw("");
|
||||
setTotpMsg("Authentification à deux facteurs désactivée.");
|
||||
} catch(e) { setTotpErr(e.message); }
|
||||
};
|
||||
|
||||
const cancelTotpSetup = () => {
|
||||
setTotpState("idle"); setTotpData(null); setTotpCode(""); setTotpErr(null);
|
||||
};
|
||||
|
||||
const isOidc = user?.auth_method === "oidc";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">◉</span> Mon compte</div>
|
||||
<div className="page-sub">{user?.username} — {user?.email || "aucun email renseigné"}</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{display:"flex",gap:0,marginBottom:16,borderBottom:"1px solid var(--border)"}}>
|
||||
{[
|
||||
{id:"password", label:"🔑 Mot de passe"},
|
||||
{id:"totp", label:"🔐 Double authentification"},
|
||||
].map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)} className="btn btn-ghost"
|
||||
style={{
|
||||
borderRadius:"4px 4px 0 0", marginBottom:-1, padding:"8px 18px",
|
||||
borderBottom: tab===t.id ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
color: tab===t.id ? "var(--accent)" : "var(--text-2)",
|
||||
fontWeight: tab===t.id ? 600 : 400,
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Password tab ────────────────────────────────────────────────── */}
|
||||
{tab === "password" && (
|
||||
<div className="card" style={{maxWidth:460}}>
|
||||
<div className="card-header"><div className="card-title">Changer le mot de passe</div></div>
|
||||
<div className="card-body">
|
||||
{isOidc && (
|
||||
<div className="alert" style={{background:"var(--bg-2)",border:"1px solid var(--border)",
|
||||
marginBottom:16,color:"var(--text-2)"}}>
|
||||
ℹ Votre compte utilise la connexion SSO (Keycloak) — le mot de passe est géré par votre fournisseur d'identité.
|
||||
</div>
|
||||
)}
|
||||
{pwMsg && <div className="alert alert-success" style={{marginBottom:12}}>✓ {pwMsg}</div>}
|
||||
{pwErr && <div className="alert alert-error" style={{marginBottom:12}}>⚠ {pwErr}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Mot de passe actuel</label>
|
||||
<input className="form-input" type="password" value={pwForm.current}
|
||||
onChange={e => setPwForm({...pwForm, current:e.target.value})}
|
||||
disabled={isOidc} autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nouveau mot de passe</label>
|
||||
<input className="form-input" type="password" value={pwForm.next}
|
||||
onChange={e => setPwForm({...pwForm, next:e.target.value})}
|
||||
disabled={isOidc} autoComplete="new-password" />
|
||||
<div style={{fontSize:10,color:"var(--text-3)",marginTop:3}}>Minimum 8 caractères</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Confirmer le nouveau mot de passe</label>
|
||||
<input className="form-input" type="password" value={pwForm.confirm}
|
||||
onChange={e => setPwForm({...pwForm, confirm:e.target.value})}
|
||||
disabled={isOidc} autoComplete="new-password" />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={changePassword}
|
||||
disabled={pwSaving || isOidc}>
|
||||
{pwSaving ? "Sauvegarde…" : "Changer le mot de passe"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TOTP tab ─────────────────────────────────────────────────────── */}
|
||||
{tab === "totp" && (
|
||||
<div className="card" style={{maxWidth:500}}>
|
||||
<div className="card-header">
|
||||
<div className="card-title">Double authentification (TOTP)</div>
|
||||
{totpEnabled && <span className="badge badge-active">✓ Activée</span>}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{isOidc ? (
|
||||
<div style={{padding:"20px 0",textAlign:"center",color:"var(--text-2)",fontSize:13}}>
|
||||
<div style={{fontSize:32,marginBottom:12}}>🔐</div>
|
||||
<div>La double authentification n'est disponible que pour les comptes locaux.</div>
|
||||
<div style={{marginTop:6,fontSize:11,color:"var(--text-3)"}}>
|
||||
Les comptes SSO utilisent la 2FA de leur fournisseur d'identité (Keycloak).
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
{totpMsg && <div className="alert alert-success" style={{marginBottom:12}}>✓ {totpMsg}</div>}
|
||||
{totpErr && <div className="alert alert-error" style={{marginBottom:12}}>⚠ {totpErr}</div>}
|
||||
|
||||
{/* ── Idle state ── */}
|
||||
{totpState === "idle" && !totpEnabled && (
|
||||
<>
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:16}}>
|
||||
La double authentification ajoute une couche de sécurité supplémentaire.
|
||||
À chaque connexion, un code à usage unique sera demandé en plus de votre mot de passe.
|
||||
</p>
|
||||
{user?.totp_required && (
|
||||
<div style={{padding:"8px 12px",background:"rgba(255,200,0,0.1)",
|
||||
border:"1px solid rgba(255,200,0,0.3)",borderRadius:6,fontSize:12,
|
||||
color:"var(--yellow)",marginBottom:12}}>
|
||||
⚠ Votre administrateur a rendu la double authentification obligatoire pour votre compte.
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={startTotpSetup}>
|
||||
🔐 Configurer la double authentification
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{totpState === "idle" && totpEnabled && (
|
||||
<>
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:16}}>
|
||||
La double authentification est <strong style={{color:"var(--green)"}}>activée</strong> sur votre compte.
|
||||
Vous utiliserez une application d'authentification (Google Authenticator, Aegis, etc.)
|
||||
à chaque connexion.
|
||||
</p>
|
||||
<button className="btn btn-secondary"
|
||||
onClick={() => { setTotpState("disabling"); setTotpErr(null); }}>
|
||||
○ Désactiver la double authentification
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Setup: show QR code ── */}
|
||||
{totpState === "verifying" && totpData && (
|
||||
<>
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:12}}>
|
||||
<strong>Étape 1</strong> — Scannez ce QR code avec votre application d'authentification
|
||||
(Google Authenticator, Aegis, Authy, etc.).
|
||||
</p>
|
||||
<div style={{textAlign:"center",margin:"16px 0"}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${totpData.qr_b64}`}
|
||||
alt="QR code TOTP"
|
||||
style={{width:200,height:200,imageRendering:"pixelated",
|
||||
border:"4px solid white",borderRadius:4}}
|
||||
/>
|
||||
</div>
|
||||
<details style={{marginBottom:12}}>
|
||||
<summary style={{fontSize:11,color:"var(--text-3)",cursor:"pointer"}}>
|
||||
Afficher la clé secrète (saisie manuelle)
|
||||
</summary>
|
||||
<div style={{fontFamily:"var(--font-mono)",fontSize:13,letterSpacing:"0.1em",
|
||||
padding:"8px 12px",background:"var(--bg-0)",borderRadius:4,marginTop:6,
|
||||
wordBreak:"break-all",color:"var(--accent)"}}>
|
||||
{totpData.secret}
|
||||
</div>
|
||||
</details>
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:8}}>
|
||||
<strong>Étape 2</strong> — Saisissez le code affiché par l'application pour confirmer la configuration.
|
||||
</p>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
<input className="form-input" placeholder="123456"
|
||||
value={totpCode} onChange={e => setTotpCode(e.target.value.replace(/\D/g,"").slice(0,6))}
|
||||
onKeyDown={e => e.key === "Enter" && totpCode.length === 6 && verifyTotp()}
|
||||
style={{width:140,fontFamily:"var(--font-mono)",fontSize:18,
|
||||
letterSpacing:"0.15em",textAlign:"center"}}
|
||||
maxLength={6} autoFocus />
|
||||
<button className="btn btn-primary" onClick={verifyTotp}
|
||||
disabled={totpCode.length !== 6}>
|
||||
Vérifier et activer
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={cancelTotpSetup}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Disable: ask password ── */}
|
||||
{totpState === "disabling" && (
|
||||
<>
|
||||
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:12}}>
|
||||
Pour désactiver la double authentification, confirmez votre mot de passe.
|
||||
</p>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
<input className="form-input" type="password" placeholder="Mot de passe"
|
||||
value={totpPw} onChange={e => setTotpPw(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && totpPw && disableTotp()}
|
||||
style={{width:220}} autoFocus />
|
||||
<button className="btn btn-danger" onClick={disableTotp} disabled={!totpPw}>
|
||||
Désactiver
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm"
|
||||
onClick={() => { setTotpState("idle"); setTotpPw(""); setTotpErr(null); }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const ROLE_LABELS = {
|
||||
global_admin: { label: "Admin global", color: "var(--red)", desc: "Accès complet" },
|
||||
dns_admin: { label: "Admin DNS", color: "var(--accent)", desc: "Toutes les zones DNS" },
|
||||
mail_admin: { label: "Admin Mail", color: "var(--yellow)", desc: "Tous les comptes mail" },
|
||||
domain_admin: { label: "Admin domaine", color: "var(--green)", desc: "DNS + mail" },
|
||||
dns_zone_admin: { label: "Admin zone DNS", color: "#9d7aff", desc: "Zone DNS spécifique" },
|
||||
mail_domain_admin: { label: "Admin mail domaine", color: "var(--orange)", desc: "Mail d'un domaine" },
|
||||
};
|
||||
|
||||
const SCOPE_REQUIRED = ["domain_admin", "dns_zone_admin", "mail_domain_admin"];
|
||||
|
||||
function RoleBadge({ role, scope }) {
|
||||
const info = ROLE_LABELS[role] || { label: role, color: "var(--text-2)" };
|
||||
return (
|
||||
<span style={{
|
||||
display:"inline-flex", alignItems:"center", gap:4,
|
||||
padding:"2px 8px", borderRadius:3, fontSize:10, fontWeight:500,
|
||||
background: info.color + "22", color: info.color,
|
||||
border: `1px solid ${info.color}44`,
|
||||
}}>
|
||||
{info.label}{scope ? ` (${scope})` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersManager({ apiBase, token, currentUser }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editUser, setEditUser] = useState(null);
|
||||
const [showRoleModal, setShowRoleModal] = useState(null); // user object
|
||||
const [form, setForm] = useState({ username:"", password:"", email:"", full_name:"", auth_method:"local" });
|
||||
const [roleForm, setRoleForm] = useState({ role:"dns_admin", scopes:[] });
|
||||
const [allDomains, setAllDomains] = useState([]);
|
||||
const [domainSearch, setDomainSearch] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmUser, setConfirmUser] = useState(null); // user pending deletion
|
||||
const [confirmRole, setConfirmRole] = useState(null); // {userId, roleId, role, scope}
|
||||
const [oidcEnabled, setOidcEnabled] = useState(false);
|
||||
|
||||
const api = useCallback(async (path, options = {}) => {
|
||||
const r = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
...options,
|
||||
});
|
||||
if (r.status === 401) {
|
||||
onUnauthorized?.();
|
||||
throw new Error("Session expirée — veuillez vous reconnecter.");
|
||||
}
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({ detail: r.statusText }));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
if (r.status === 204) return null;
|
||||
return r.json();
|
||||
}, [apiBase, token]);
|
||||
|
||||
// Fetch all domains for scope selection
|
||||
const loadDomains = useCallback(() => {
|
||||
fetch(`${apiBase}/mail/domains-config`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then(r => r.ok ? r.json() : [])
|
||||
.then(d => setAllDomains(d.map(x => x.name)))
|
||||
.catch(() => {});
|
||||
}, [apiBase, token]);
|
||||
|
||||
// Fetch server config once to know if OIDC is available
|
||||
useEffect(() => {
|
||||
fetch(`${apiBase}/auth/config`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.oidc_enabled) setOidcEnabled(true); })
|
||||
.catch(() => {});
|
||||
}, [apiBase]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([api("/auth/users"), api("/auth/roles")])
|
||||
.then(([u, r]) => { setUsers(u); setRoles(r); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => { load(); loadDomains(); }, [load, loadDomains]);
|
||||
|
||||
const openNew = () => { setForm({ username:"", password:"", email:"", full_name:"", auth_method:"local" }); setEditUser(null); setShowModal(true); };
|
||||
const openEdit = (u) => { setForm({ username:u.username, password:"", email:u.email||"", full_name:u.full_name||"", auth_method:u.auth_method }); setEditUser(u); setShowModal(true); };
|
||||
|
||||
const saveUser = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editUser) {
|
||||
const body = {};
|
||||
if (form.password) body.password = form.password;
|
||||
if (form.email !== editUser.email) body.email = form.email;
|
||||
if (form.full_name !== editUser.full_name) body.full_name = form.full_name;
|
||||
await api(`/auth/users/${editUser.id}`, { method:"PUT", body:JSON.stringify(body) });
|
||||
} else {
|
||||
await api("/auth/users", { method:"POST", body:JSON.stringify(form) });
|
||||
}
|
||||
load(); setShowModal(false);
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const toggleActive = async (u) => {
|
||||
try { await api(`/auth/users/${u.id}`, { method:"PUT", body:JSON.stringify({ active: !u.active }) }); load(); }
|
||||
catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const deleteUser = async (u) => { setConfirmUser(u); };
|
||||
|
||||
const doDeleteUser = async () => {
|
||||
const u = confirmUser; setConfirmUser(null);
|
||||
try { await api(`/auth/users/${u.id}`, { method:"DELETE" }); load(); }
|
||||
catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const assignRole = async () => {
|
||||
if (!showRoleModal) return;
|
||||
const needsScope = SCOPE_REQUIRED.includes(roleForm.role);
|
||||
if (needsScope && roleForm.scopes.length === 0) { setError("Sélectionnez au moins un domaine"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (needsScope) {
|
||||
// Send all scopes in one request
|
||||
await api(`/auth/users/${showRoleModal.id}/roles`, {
|
||||
method:"POST",
|
||||
body: JSON.stringify({ role: roleForm.role, scopes: roleForm.scopes }),
|
||||
});
|
||||
} else {
|
||||
await api(`/auth/users/${showRoleModal.id}/roles`, {
|
||||
method:"POST",
|
||||
body: JSON.stringify({ role: roleForm.role, scope: null }),
|
||||
});
|
||||
}
|
||||
load(); setShowRoleModal(null); setDomainSearch("");
|
||||
} catch(e) { setError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const revokeRole = (userId, roleId, role, scope) => {
|
||||
setConfirmRole({ userId, roleId, role, scope });
|
||||
};
|
||||
|
||||
const doRevokeRole = async () => {
|
||||
const { userId, roleId } = confirmRole; setConfirmRole(null);
|
||||
try { await api(`/auth/users/${userId}/roles/${roleId}`, { method:"DELETE" }); load(); }
|
||||
catch(e) { setError(e.message); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title"><span className="icon">◉</span> Utilisateurs & Droits</div>
|
||||
<div className="page-sub">Gestion des comptes et des permissions</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
⚠ {error}
|
||||
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={() => setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roles reference */}
|
||||
<div className="card" style={{marginBottom:16}}>
|
||||
<div className="card-header"><div className="card-title">Rôles disponibles</div></div>
|
||||
<div className="card-body" style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||
{Object.entries(ROLE_LABELS).map(([role, info]) => (
|
||||
<div key={role} style={{
|
||||
padding:"8px 12px", background:"var(--bg-2)", borderRadius:6,
|
||||
border:"1px solid var(--border)", minWidth:160,
|
||||
}}>
|
||||
<RoleBadge role={role} />
|
||||
<div style={{fontSize:10,color:"var(--text-2)",marginTop:4}}>{info.desc}</div>
|
||||
{SCOPE_REQUIRED.includes(role) && (
|
||||
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>⚡ Scope requis</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">◉ Utilisateurs ({users.length})</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={openNew}>+ Nouvel utilisateur</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"/> Chargement…</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Utilisateur</th>
|
||||
<th>Email</th>
|
||||
<th>Méthode</th>
|
||||
<th>Statut</th>
|
||||
<th>Rôles</th>
|
||||
<th>Dernière connexion</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td className="cell-mono">
|
||||
{u.username}
|
||||
{u.id === currentUser?.id && (
|
||||
<span style={{fontSize:10,color:"var(--accent)",marginLeft:6}}>(vous)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="cell-dim">{u.email || "—"}</td>
|
||||
<td>
|
||||
<span className={`badge ${u.auth_method === "oidc" ? "badge-mx" : "badge-a"}`}>
|
||||
{u.auth_method === "oidc" ? "OpenID" : "Local"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{whiteSpace:"nowrap"}}>
|
||||
{u.totp_enabled
|
||||
? <span className="badge badge-active" title="2FA activée">🔐 Activée</span>
|
||||
: u.totp_required
|
||||
? <span style={{fontSize:11,color:"var(--yellow)"}} title="Obligatoire, non configurée">⚠ Requise</span>
|
||||
: <span style={{fontSize:11,color:"var(--text-3)"}}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.active ? "badge-active" : "badge-inactive"}`}>
|
||||
{u.active ? "Actif" : "Désactivé"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{display:"flex",gap:3,flexWrap:"wrap",maxWidth:280}}>
|
||||
{u.roles.length === 0 && <span style={{fontSize:11,color:"var(--text-3)"}}>Aucun rôle</span>}
|
||||
{u.roles.map((r, i) => (
|
||||
<div key={i} style={{display:"flex",alignItems:"center",gap:2}}>
|
||||
<RoleBadge role={r.role} scope={r.scope} />
|
||||
{u.id !== currentUser?.id && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{padding:"1px 4px",fontSize:10,color:"var(--text-3)"}}
|
||||
onClick={() => revokeRole(u.id, r.id, r.role, r.scope)}
|
||||
title="Révoquer"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{fontSize:11,color:"var(--accent)",padding:"1px 6px"}}
|
||||
onClick={() => { setShowRoleModal(u); setRoleForm({ role:"dns_admin", scopes:[] }); setDomainSearch(""); }}
|
||||
>+ Rôle</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="cell-dim" style={{fontSize:11}}>
|
||||
{u.last_login ? new Date(u.last_login).toLocaleString("fr-FR") : "Jamais"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(u)} title="Modifier">✎</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => toggleActive(u)} title={u.active ? "Désactiver" : "Activer"}>
|
||||
{u.active ? "⏸" : "▶"}
|
||||
</button>
|
||||
{u.totp_enabled && (
|
||||
<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--text-3)"}}
|
||||
title="Supprimer la 2FA (l'utilisateur devra la reconfigurer)"
|
||||
onClick={() => handleTotpReset(u.id, u.username)}>🔐✕</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm"
|
||||
style={{fontSize:11,color:u.totp_required?"var(--yellow)":"var(--text-3)"}}
|
||||
title={u.totp_required ? "Retirer l'obligation 2FA" : "Forcer la 2FA pour cet utilisateur"}
|
||||
onClick={() => handleTotpRequire(u.id, u.username, !u.totp_required)}>
|
||||
{u.totp_required ? "🔐✓" : "🔐?"}
|
||||
</button>
|
||||
{u.id !== currentUser?.id && (
|
||||
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={() => deleteUser(u)} title="Supprimer">✕</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setShowModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{editUser ? "Modifier l'utilisateur" : "Nouvel utilisateur"}</div>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Identifiant</label>
|
||||
<input className="form-input" placeholder="jdupont" value={form.username}
|
||||
onChange={e => setForm({...form, username:e.target.value})} disabled={!!editUser} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Méthode d'auth</label>
|
||||
<select className="form-select" value={form.auth_method}
|
||||
onChange={e => setForm({...form, auth_method:e.target.value})} disabled={!!editUser}>
|
||||
<option value="local">Local (mot de passe)</option>
|
||||
{oidcEnabled && <option value="oidc">OpenID Connect</option>}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{form.auth_method === "local" && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">{editUser ? "Nouveau mot de passe (laisser vide = inchangé)" : "Mot de passe"}</label>
|
||||
<input className="form-input" type="password" placeholder={editUser ? "••••••••" : "Mot de passe"}
|
||||
value={form.password} onChange={e => setForm({...form, password:e.target.value})} />
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nom complet</label>
|
||||
<input className="form-input" placeholder="Jean Dupont" value={form.full_name}
|
||||
onChange={e => setForm({...form, full_name:e.target.value})} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input className="form-input" placeholder="jdupont@example.com" value={form.email}
|
||||
onChange={e => setForm({...form, email:e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={saveUser} disabled={saving}>
|
||||
{saving ? "Sauvegarde…" : editUser ? "Mettre à jour" : "Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role modal */}
|
||||
{showRoleModal && (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setShowRoleModal(null)}>
|
||||
<div className="modal" style={{maxWidth:460}}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">Assigner un rôle — {showRoleModal.username}</div>
|
||||
<button className="modal-close" onClick={() => setShowRoleModal(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Rôle</label>
|
||||
<select className="form-select" value={roleForm.role}
|
||||
onChange={e => setRoleForm({...roleForm, role:e.target.value, scopes:[]})}>
|
||||
{roles.map(r => (
|
||||
<option key={r.role} value={r.role}>
|
||||
{ROLE_LABELS[r.role]?.label || r.role} — {r.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{roleForm.role && ROLE_LABELS[roleForm.role] && (
|
||||
<div style={{marginTop:6,padding:"6px 10px",background:"var(--bg-0)",
|
||||
borderRadius:4,fontSize:11,color:"var(--text-2)"}}>
|
||||
{ROLE_LABELS[roleForm.role].desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{SCOPE_REQUIRED.includes(roleForm.role) && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{roleForm.role === "dns_zone_admin" ? "Zone(s) DNS" : "Domaine(s)"}
|
||||
{roleForm.scopes.length > 0 && (
|
||||
<span style={{color:"var(--accent)",marginLeft:6}}>
|
||||
{roleForm.scopes.length} sélectionné{roleForm.scopes.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Search input */}
|
||||
<input className="form-input" placeholder="Rechercher un domaine…"
|
||||
value={domainSearch}
|
||||
onChange={e => setDomainSearch(e.target.value)}
|
||||
style={{marginBottom:6}}
|
||||
/>
|
||||
|
||||
{/* Domain list with checkboxes */}
|
||||
<div style={{
|
||||
maxHeight:200, overflowY:"auto",
|
||||
border:"1px solid var(--border)", borderRadius:6,
|
||||
background:"var(--bg-1)",
|
||||
}}>
|
||||
{allDomains.filter(d => !domainSearch || d.toLowerCase().includes(domainSearch.toLowerCase()))
|
||||
.map(d => {
|
||||
const checked = roleForm.scopes.includes(d);
|
||||
return (
|
||||
<label key={d} style={{
|
||||
display:"flex", alignItems:"center", gap:10,
|
||||
padding:"8px 12px", cursor:"pointer",
|
||||
background: checked ? "var(--accent-dim)" : "transparent",
|
||||
borderBottom:"1px solid var(--border)",
|
||||
transition:"background 0.1s",
|
||||
}}>
|
||||
<input type="checkbox" checked={checked}
|
||||
onChange={() => {
|
||||
const next = checked
|
||||
? roleForm.scopes.filter(s => s !== d)
|
||||
: [...roleForm.scopes, d];
|
||||
setRoleForm({...roleForm, scopes: next});
|
||||
}}
|
||||
style={{accentColor:"var(--accent)",width:14,height:14}}
|
||||
/>
|
||||
<span style={{
|
||||
fontFamily:"var(--font-mono)", fontSize:12,
|
||||
color: checked ? "var(--accent)" : "var(--text-1)",
|
||||
}}>{d}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
}
|
||||
{allDomains.filter(d => !domainSearch || d.toLowerCase().includes(domainSearch.toLowerCase())).length === 0 && (
|
||||
<div style={{padding:"12px",color:"var(--text-3)",fontSize:12,textAlign:"center"}}>
|
||||
Aucun domaine trouvé
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected chips */}
|
||||
{roleForm.scopes.length > 0 && (
|
||||
<div style={{display:"flex",flexWrap:"wrap",gap:4,marginTop:8}}>
|
||||
{roleForm.scopes.map(s => (
|
||||
<span key={s} style={{
|
||||
display:"inline-flex",alignItems:"center",gap:4,
|
||||
padding:"2px 8px",borderRadius:3,fontSize:11,
|
||||
background:"var(--accent-dim)",color:"var(--accent)",
|
||||
border:"1px solid rgba(0,212,255,0.2)",
|
||||
}}>
|
||||
{s}
|
||||
<button onClick={() => setRoleForm({...roleForm, scopes:roleForm.scopes.filter(x=>x!==s)})}
|
||||
style={{background:"none",border:"none",color:"var(--accent)",cursor:"pointer",
|
||||
fontSize:12,lineHeight:1,padding:0}}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{fontSize:11,color:"var(--text-3)",marginTop:6}}>
|
||||
{roleForm.role === "domain_admin" && "Accès complet (DNS + mail) pour ces domaines."}
|
||||
{roleForm.role === "dns_zone_admin" && "Gestion des enregistrements DNS de ces zones."}
|
||||
{roleForm.role === "mail_domain_admin" && "Gestion des comptes et listes mail de ces domaines."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => { setShowRoleModal(null); setDomainSearch(""); }}>Annuler</button>
|
||||
<button className="btn btn-primary" onClick={assignRole}
|
||||
disabled={saving || (SCOPE_REQUIRED.includes(roleForm.role) && roleForm.scopes.length === 0)}>
|
||||
{saving ? "…" : "Assigner"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete User Confirmation */}
|
||||
{confirmUser && (
|
||||
<div className="modal-overlay" onClick={() => setConfirmUser(null)}>
|
||||
<div className="modal" style={{maxWidth:400}} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer l'utilisateur</div>
|
||||
<button className="modal-close" onClick={() => setConfirmUser(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
|
||||
Supprimer définitivement le compte{" "}
|
||||
<strong style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>{confirmUser.username}</strong>
|
||||
{confirmUser.full_name && ` (${confirmUser.full_name})`} ?
|
||||
</p>
|
||||
<div style={{marginTop:10,padding:"8px 12px",background:"var(--red-dim)",
|
||||
border:"1px solid rgba(255,74,106,0.2)",borderRadius:6,fontSize:11,color:"var(--red)"}}>
|
||||
⚠ Ses sessions et rôles seront également supprimés. Action irréversible.
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setConfirmUser(null)}>Annuler</button>
|
||||
<button className="btn btn-danger" onClick={doDeleteUser}>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke Role Confirmation */}
|
||||
{confirmRole && (
|
||||
<div className="modal-overlay" onClick={() => setConfirmRole(null)}>
|
||||
<div className="modal" style={{maxWidth:400}} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" style={{color:"var(--yellow)"}}>Révoquer un rôle</div>
|
||||
<button className="modal-close" onClick={() => setConfirmRole(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
|
||||
Révoquer le rôle suivant ?
|
||||
</p>
|
||||
<div style={{marginTop:10,padding:"10px 14px",background:"var(--bg-0)",
|
||||
border:"1px solid var(--border)",borderRadius:6}}>
|
||||
<RoleBadge role={confirmRole.role} scope={confirmRole.scope} />
|
||||
{confirmRole.scope && (
|
||||
<div style={{fontSize:11,color:"var(--text-2)",marginTop:6}}>
|
||||
Domaine / zone : <code style={{color:"var(--accent)"}}>{confirmRole.scope}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setConfirmRole(null)}>Annuler</button>
|
||||
<button className="btn btn-danger" onClick={doRevokeRole}>Révoquer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/dns': 'http://localhost:8000',
|
||||
'/mail': 'http://localhost:8000',
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user