refactor: refactorisation, validation et corrections mail/DNS

Refactorisation
- Hook useApi centralisé (hooks/useApi.js) — supprime 200 lignes dupliquées
- Helpers backend : get_or_404, _sync_domain_from_state, _sync_zone_from_state, _stable_id
- Constantes de rôles RBAC dans auth.py (ROLE_GLOBAL_ADMIN, etc.)
- handle_apply_domain découpé en _sync_domain_row / _sync_mailboxes / _sync_aliases (169→32 lignes)
- oidc_callback découpé en _exchange_oidc_token / _provision_oidc_user (159→54 lignes)
- Extraction composants : AccountModal, AliasModal, DnssecResultModal, DnssecManageModal
- Suppression MailingLists.jsx (inutilisé)

Corrections
- Notification de connexion différée après validation TOTP
- Création domaine : validation complète avant tout appel API (quota bal > quota global bloqué)
- Création domaine : service Mail non tenté si création DNS échoue
- Alias goto : validation email bloquante (frontend + backend)
- Alias : vérification conflit avec compte mail existant (409)
- delete_domain : notifie l'agent mail pour suppression dans netadmin_mail
- Clés DNSSEC archivées dans key_dir/old/ avant activation et à la suppression de zone
- _get_ds_records : exclut le dossier old/ (évitait la lecture de doublons)
- Modale TOTP : remplace window.confirm par une vraie modale
- "Mon compte" accessible sans rôle
- Suppression champ "Serveur API" de la sidebar
This commit is contained in:
2026-04-24 15:36:29 +02:00
parent 17664ef038
commit b12ebea0e8
30 changed files with 3621 additions and 2095 deletions
+207 -285
View File
@@ -1,368 +1,290 @@
# 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.
Interface d'administration DNS et Mail pour serveurs BIND9 + Postfix/Dovecot.
---
## 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) │ │ │
└─────────────┘ └──────────────────┘
┌─────────────────────────────────────────────────────────
│ Frontend React (Vite) :3000
Backend FastAPI :8000
MariaDB (base: netadmin) — données NetAdmin
│ MariaDB (base: netadmin_mail) — données Postfix │
Redis mTLS broker :6380
│ │ │
dns_agent.py ←→ dns.commands/acks → BIND9
mail_agent.py ←→ mail.commands/acks → Postfix
└─────────────────────────────────────────────────────────┘
```
### 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
### Prérequis
```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;
```
- Python 3.11+
- Node.js 18+
- MariaDB 10.6+
- Redis 7+
- BIND9 9.20+
- Postfix + Dovecot
- Rspamd (optionnel, pour DKIM)
Les tables sont créées automatiquement au premier démarrage du backend.
### 2. Backend
### Backend
```bash
cd backend/
python3 -m venv venv
source venv/bin/activate
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
cp config.yaml /etc/netadmin/config.yaml # adapter les valeurs
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
### Frontend
```bash
cd frontend/
npm install
npm run build # Production
npm run build # production
# ou
npm run dev # Développement
npm run dev # développement :3000
```
Le build de production est dans `frontend/dist/` — à servir via nginx ou autre.
### 4. Certificats mTLS Redis
### Agents
```bash
# Agent DNS (sur le serveur BIND9)
cd agents/
bash gen_certs.sh
pip install -r requirements.txt
cp ../backend/config.yaml /etc/netadmin/config.yaml # section dns_agent
python3 dns_agent.py
# Agent Mail (sur le serveur Postfix)
python3 mail_agent.py
```
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)
### Services systemd
```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
cp agents/netadmin-dns-agent.service /etc/systemd/system/
cp agents/netadmin-mail-agent.service /etc/systemd/system/
systemctl enable --now netadmin-dns-agent netadmin-mail-agent
```
---
## Configuration (`config.yaml`)
## Configuration
### `backend/config.yaml`
```yaml
# ── Serveur ───────────────────────────────────────────────────────────────
server:
host: "0.0.0.0"
port: 8000
cors_origins:
- "http://localhost:3000"
- "https://netadmin.example.com"
dns:
default_ns:
- "ns1.example.fr."
- "ns2.example.fr."
default_soa_admin: "hostmaster.example.fr"
dnssec_enabled: true
key_dir: "/etc/bind/keys"
# ── Base de données MariaDB ───────────────────────────────────────────────
database:
host: "localhost"
port: 3306
user: "netadmin"
password: "motdepasse"
name: "netadmin"
mail_default_aliases:
abuse: "postmaster@example.fr"
hostmaster: "postmaster@example.fr"
postmaster: "admin@example.fr"
webmaster: "admin@example.fr"
# ── 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"
dns_agent:
zones_dir: "/etc/bind/zones"
named_conf_local: "/etc/bind/named.conf.local"
rndc_cmd: "rndc"
key_dir: "/etc/bind/keys"
dnssec_policy: "default"
# ── 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
mail_agent:
dkim_dir: "/var/lib/rspamd/dkim"
dkim_selector: "mail"
```
# ── 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"
### Postfix `main.cf`
# ── Synchronisation ───────────────────────────────────────────────────────
sync:
interval: 300 # Polling automatique toutes les 5 minutes (0 = désactivé)
```ini
virtual_mailbox_domains = mysql:/etc/postfix/mysql_virtual_mailbox_domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/mysql_virtual_mailbox_maps.cf
virtual_alias_maps = mysql:/etc/postfix/mysql_virtual_alias_maps.cf
smtpd_sender_login_maps = mysql:/etc/postfix/mysql_sender_login_maps.cf
smtpd_sender_restrictions = reject_sender_login_mismatch, permit
```
### Fichiers `.cf` Postfix (pointent sur `netadmin_mail`)
```ini
# mysql_virtual_mailbox_domains.cf
query = SELECT domain FROM domain WHERE domain='%s' AND active=1
# mysql_virtual_mailbox_maps.cf
query = SELECT CONCAT(domain,'/',SUBSTRING_INDEX(username,'@',1),'/')
FROM mailbox WHERE username='%s' AND active=1
# mysql_virtual_alias_maps.cf
query = SELECT goto FROM alias WHERE address='%s' AND active=1
# mysql_sender_login_maps.cf
query = SELECT authorized FROM sender_login_maps WHERE sender='%s'
```
---
## Rôles et permissions
## Migration depuis PostfixAdmin
| 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 |
```bash
# Aperçu sans modification
python3 migrate_postfixadmin.py --dry-run \
--user root --password xxx \
--source-db postfix --target-db netadmin_mail
# Migration réelle
python3 migrate_postfixadmin.py \
--user root --password xxx \
--source-db postfix --target-db netadmin_mail
```
Le script migre : domaines, boîtes aux lettres (SHA512-CRYPT conservé), alias.
Les mots de passe `$1$` (MD5) ou `$2$` (bcrypt) sont marqués `{MIGRATION_REQUIRED}`
et le compte est désactivé — reset admin requis.
### Scripts SQL de correction
```bash
# Corriger les alias avec source en partie locale seulement
mysql netadmin -e "UPDATE aliases SET source = CONCAT(source, '@', domain) WHERE source NOT LIKE '%@%';"
# Corriger les double domaines dans mailbox (si migration partielle)
mysql netadmin_mail -e "
UPDATE mailbox
SET username = CONCAT(SUBSTRING_INDEX(username,'@',1),'@',SUBSTRING_INDEX(username,'@',-1))
WHERE username LIKE '%@%@%';"
```
---
## 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
- Gestion des zones BIND9 (création via Domaines uniquement)
- Enregistrements : A, AAAA, CNAME, MX, TXT, NS, PTR, SRV, CAA, TLSA, DS
- DNSSEC par zone (activation/désactivation, récupération des clés DS)
- NS par défaut configurables, email SOA admin par défaut
- Archivage automatique des clés DNSSEC dans `key_dir/old/`
### 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
- Gestion des domaines mail avec quotas (global + par BAL)
- Boîtes aux lettres avec quota individuel et nom affiché
- Alias avec activation/désactivation
- DKIM via Rspamd (`rspamadm dkim_keygen`)
- Alias RFC obligatoires créés automatiquement (abuse, postmaster, hostmaster, webmaster)
- Sender login maps (prochainement)
- Mots de passe hashés en `{SHA512-CRYPT}` via `passlib`
### 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)
- Authentification locale + OpenID Connect (Keycloak)
- TOTP (2FA) par compte, obligatoire ou optionnel
- RBAC : global_admin, dns_admin, mail_admin, domain_admin, dns_zone_admin
- Préférences utilisateur (thème clair/sombre) persistées en BDD
- Audit log complet
- Notifications email à la connexion
### 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
### Interface
- Thème sombre / clair
- Création de domaine unifiée (DNS + Mail)
- Validation des enregistrements DNS (FQDN, IPv4, IPv6)
- Affichage des enregistrements DS détaillés (Key Tag, Algorithme, Digest)
- Interface entièrement en français
---
## Structure du projet
## Sécurité
### Certificats mTLS Redis
```bash
cd agents/
bash gen_certs.sh # génère CA + certificats agents
```
Copier les certificats dans `/etc/netadmin/certs/`.
### Premier compte admin
Au premier démarrage, NetAdmin crée automatiquement :
- Utilisateur : `admin`
- Mot de passe : `admin`
- **Changer immédiatement après connexion.**
---
## Structure des fichiers
```
netadmin/
├── backend/
│ ├── main.py # Point d'entrée FastAPI
│ ├── main.py # FastAPI app
│ ├── 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)
│ ├── config.yaml # Configuration principale
│ ├── db.py # Connexion MariaDB (netadmin)
│ ├── broker.py # File Redis + pending_actions
│ ├── audit.py # Audit log
│ ├── notify.py # Notifications email
│ ├── requirements.txt
│ └── routers/
│ ├── auth.py # Authentification, RBAC, TOTP
│ ├── dns.py # Zones et enregistrements DNS
│ └── mail.py # Comptes, alias, domaines mail
│ ├── auth.py # Auth locale + OIDC + TOTP + RBAC
│ ├── dns.py # Zones + enregistrements + DNSSEC
│ └── mail.py # Domaines + comptes + alias + DKIM
├── agents/
│ ├── dns_agent.py # Agent BIND9
│ ├── mail_agent.py # Agent Postfix/Dovecot/Rspamd
│ ├── requirements.txt
│ ├── gen_certs.sh # Génération certificats mTLS
│ ├── netadmin-dns-agent.service
│ └── netadmin-mail-agent.service
├── frontend/
│ ├── index.html
│ ├── src/
│ │ ├── App.jsx # Routing + sidebar + auth
│ │ ├── App.css # Thème sombre/clair
│ │ ├── main.jsx # Point d'entrée + polices
│ │ └── components/
│ │ ├── LoginPage.jsx
│ │ ├── DomainsManager.jsx # DNS + Mail par domaine
│ │ ├── DNSManager.jsx # Enregistrements + DNSSEC
│ │ ├── MailManager.jsx # Comptes + alias
│ │ ├── UsersManager.jsx # Utilisateurs + rôles
│ │ ├── UserProfile.jsx # Mon compte + 2FA + thème
│ │ ├── AuditLog.jsx
│ │ ├── SyncDashboard.jsx
│ │ └── SyncToast.jsx
│ ├── 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
── vite.config.js
├── migrate_postfixadmin.py # Migration depuis PostfixAdmin
├── fix_alias_source.sql # Correction alias partiels
├── fix_double_domain.sql # Correction double domaine
└── README.md
```
---
## Déploiement nginx (exemple)
## Bases de données
```nginx
# Frontend
server {
listen 443 ssl;
server_name netadmin.example.com;
### `netadmin` (gestion NetAdmin)
- `users`, `user_roles`, `sessions`, `user_preferences`
- `zones`, `records` (DNS)
- `domains`, `accounts`, `aliases` (Mail NetAdmin)
- `pending_actions`, `audit_log`, `sync_history`
root /opt/netadmin/frontend/dist;
index index.html;
### `netadmin_mail` (lue par Postfix/Dovecot)
- `domain` — domaines hébergés
- `mailbox` — boîtes aux lettres (username = email complet)
- `alias` — alias et redirections (inclut self-aliases pour virtual_alias_maps)
- `sender_login_maps` — autorisations d'envoi
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.
+317 -1
View File
@@ -63,6 +63,8 @@ DEFAULT_CFG = {
"zones_dir": "/etc/bind/zones",
"named_conf_local": "/etc/bind/named.conf.local",
"rndc_cmd": "rndc",
"key_dir": "/etc/bind/keys", # DNSSEC key directory
"dnssec_policy": "default", # BIND9 dnssec-policy name
"redis_host": "localhost",
"redis_port": 6379,
"redis_password": "",
@@ -270,6 +272,9 @@ def handle_delete_zone(payload: dict, cfg: dict) -> dict:
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
if path.exists():
path.unlink()
# Archive DNSSEC keys for this zone
key_dir = cfg.get("key_dir", "/etc/bind/keys")
_archive_zone_keys(zone_name, key_dir)
try:
subprocess.run([cfg["rndc_cmd"], "reconfig"], capture_output=True, timeout=10)
except Exception:
@@ -468,6 +473,316 @@ def handle_reload_zone(payload: dict, cfg: dict) -> dict:
return {"success": False, "error": str(e)}
def _update_zone_dnssec_in_named_conf(zone_name: str, named_conf: str,
dnssec_policy: str, enable: bool,
key_dir: str = "/etc/bind/keys") -> bool:
"""
Add or remove dnssec-policy + inline-signing in the zone block.
Returns True if the file was modified.
"""
path = Path(named_conf)
if not path.exists():
return False
content = path.read_text()
# Find the zone block
import re
pattern = re.compile(
rf'(zone\s+"{re.escape(zone_name)}"\s*\{{)([^}}]+)(\}};)',
re.DOTALL
)
m = pattern.search(content)
if not m:
log.warning(f"Zone '{zone_name}' not found in {named_conf}")
return False
zone_block_inner = m.group(2)
if enable:
# Add dnssec directives if not already present
if "dnssec-policy" in zone_block_inner:
return False # already configured
extra = (
f'\n dnssec-policy "{dnssec_policy}";'
f'\n key-directory "{key_dir}";'
)
new_inner = zone_block_inner.rstrip() + extra + "\n"
else:
# Remove dnssec directives
new_inner = re.sub(r'\n\s*dnssec-policy[^;]+;', '', zone_block_inner)
new_inner = re.sub(r'\n\s*inline-signing[^;]+;', '', new_inner)
new_inner = re.sub(r'\n\s*key-directory[^;]+;', '', new_inner)
if new_inner == zone_block_inner:
return False # nothing to remove
new_block = m.group(1) + new_inner + m.group(3)
path.write_text(content[:m.start()] + new_block + content[m.end():])
log.info(f"{'Enabled' if enable else 'Disabled'} DNSSEC for zone '{zone_name}' in {named_conf}")
return True
def _get_ds_records(zone_name: str, key_dir: str, rndc_cmd: str = "rndc") -> list:
"""
Extract DS records for a zone. Tries multiple strategies:
1. rndc dnssec -status <zone> — extracts DS from BIND's key state (BIND 9.17+)
2. dig DS @127.0.0.1 <zone> — query the live signed zone
3. dnssec-dsfromkey on .key files in key_dir — fallback for older setups
"""
ds_records = []
# Strategy 1: rndc dnssec -status (BIND 9.17+ with dnssec-policy)
try:
r = subprocess.run(
[rndc_cmd, "dnssec", "-status", zone_name],
capture_output=True, text=True, timeout=10
)
if r.returncode == 0:
import re
# Extract lines like "DS: 12345 8 2 ABCDEF..."
for line in r.stdout.splitlines():
line = line.strip()
if re.match(r"DS:", line):
# Convert to full DS RR format
ds_val = line[3:].strip()
ds_records.append(f"{zone_name}. 0 IN DS {ds_val}")
if ds_records:
log.info(f"DS records from rndc dnssec -status: {len(ds_records)}")
return ds_records
except Exception as e:
log.debug(f"rndc dnssec -status failed: {e}")
# Strategy 2: dig DS @127.0.0.1 (zone must be signed and answering)
try:
r = subprocess.run(
["dig", "+noall", "+answer", f"@127.0.0.1", "DS", zone_name],
capture_output=True, text=True, timeout=10
)
if r.returncode == 0 and "DS" in r.stdout:
ds_lines = [l.strip() for l in r.stdout.splitlines()
if "DS" in l and not l.startswith(";")]
if ds_lines:
log.info(f"DS records from dig: {len(ds_lines)}")
return ds_lines
except FileNotFoundError:
log.debug("dig not available")
except Exception as e:
log.debug(f"dig DS failed: {e}")
# Strategy 3: scan key files and run dnssec-dsfromkey
key_path = Path(key_dir)
search_dirs = []
for d in [key_path, Path("/etc/bind/keys"), Path("/var/cache/bind")]:
if d.exists() and d not in search_dirs:
search_dirs.append(d)
seen_files = set()
for search_dir in search_dirs:
if not search_dir.exists():
continue
# glob only in the directory itself — never recurse into old/
for key_file in search_dir.glob(f"K{zone_name}.+*.key"):
if key_file in seen_files:
continue
seen_files.add(key_file)
try:
key_content = key_file.read_text()
# KSK = flags 257 (SEP bit set), ZSK = flags 256
if " 257 " not in key_content:
continue
r = subprocess.run(
["dnssec-dsfromkey", str(key_file)],
capture_output=True, text=True, timeout=10
)
if r.returncode == 0 and r.stdout.strip():
for line in r.stdout.strip().splitlines():
if line not in ds_records:
ds_records.append(line)
log.info(f"DS from dnssec-dsfromkey: {key_file}")
except Exception as e:
log.warning(f"dnssec-dsfromkey error for {key_file}: {e}")
return ds_records
def _archive_zone_keys(zone_name: str, key_dir: str):
"""
Move existing DNSSEC key files for zone_name into key_dir/old/YYYYMMDD-HHMMSS/.
Called before enabling DNSSEC (to avoid duplicate keys) and on zone deletion.
"""
from datetime import datetime
key_path = Path(key_dir)
if not key_path.exists():
return
# Find all key files for this zone
files = list(key_path.glob(f"K{zone_name}.+*"))
if not files:
return
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
old_dir = key_path / "old" / f"{ts}_{zone_name}"
old_dir.mkdir(parents=True, exist_ok=True)
for f in files:
dest = old_dir / f.name
f.rename(dest)
log.info(f"Archived key {f.name}{dest}")
log.info(f"Archived {len(files)} key(s) for '{zone_name}' to {old_dir}")
def handle_enable_dnssec(payload: dict, cfg: dict) -> dict:
"""
Enable or disable DNSSEC for a zone in named.conf.local.
key_dir is read exclusively from the agent config (cfg["key_dir"]).
The payload only controls: zone, action, dnssec_policy.
Actions:
enable — add dnssec-policy + key-directory to zone block, rndc reconfig
disable — remove dnssec directives, rndc reconfig
get_ds — return current DS records without modifying anything
Zone block result in named.conf.local:
zone "example.com" {
type primary;
file "/etc/bind/zones/db.example.com";
dnssec-policy "default";
key-directory "/etc/bind/keys";
};
"""
zone_name = payload["zone"]
action = payload.get("action", "enable")
dnssec_policy = payload.get("dnssec_policy") or cfg.get("dnssec_policy", "default")
named_conf = cfg.get("named_conf_local", "/etc/bind/named.conf.local")
key_dir = cfg.get("key_dir", "/etc/bind/keys")
rndc_cmd = cfg.get("rndc_cmd", "rndc")
try:
# Ensure key directory exists and has correct ownership for BIND
key_path = Path(key_dir)
key_path.mkdir(parents=True, exist_ok=True)
# ── get_ds: no modification, just return current DS records ──────
if action == "get_ds":
ds = _get_ds_records(zone_name, key_dir, rndc_cmd)
return {
"success": True,
"zone": zone_name,
"action": "get_ds",
"ds_records": ds,
"key_dir": key_dir,
}
enable = (action != "disable")
# ── Modify named.conf.local zone block ────────────────────────────
modified = _update_zone_dnssec_in_named_conf(
zone_name, named_conf, dnssec_policy, enable, key_dir
)
if not modified:
if enable:
# Zone already has dnssec-policy — just return DS records
log.info(f"Zone '{zone_name}' already has DNSSEC configured")
ds = _get_ds_records(zone_name, key_dir, rndc_cmd)
return {
"success": True,
"zone": zone_name,
"action": action,
"already_configured": True,
"ds_records": ds,
"key_dir": key_dir,
}
else:
return {
"success": True,
"zone": zone_name,
"action": "disable",
"message": f"Zone '{zone_name}' avait déjà DNSSEC désactivé.",
}
log.info(f"named.conf.local updated for zone '{zone_name}' action={action}")
if not enable:
# ── Disable ───────────────────────────────────────────────────
subprocess.run([rndc_cmd, "reconfig"], capture_output=True, timeout=15)
return {
"success": True,
"zone": zone_name,
"action": "disable",
"message": (
f"DNSSEC désactivé pour '{zone_name}'. "
f"Pensez à supprimer les enregistrements DS chez votre registrar."
),
}
# ── Enable ────────────────────────────────────────────────────────
# Archive any existing keys to avoid BIND generating duplicates
_archive_zone_keys(zone_name, key_dir)
# Ensure key_dir exists AND is owned by bind so BIND can write keys.
# BIND with dnssec-policy generates the keys automatically on reconfig,
# but ONLY if the key-directory is writable by the bind process.
try:
import shutil, pwd
bind_uid = pwd.getpwnam("bind").pw_uid
bind_gid = pwd.getpwnam("bind").pw_gid
key_path.mkdir(parents=True, exist_ok=True)
os.chown(key_dir, bind_uid, bind_gid)
import stat
key_path.chmod(key_path.stat().st_mode | stat.S_IRWXG)
log.info(f"key_dir {key_dir} owned by bind:bind with group rwx")
except (KeyError, PermissionError, AttributeError) as e:
log.warning(
f"Could not chown {key_dir} to bind: {e}. "
f"Make sure {key_dir} is writable by the bind user, "
f"or run the agent as root/with sudo."
)
# rndc reconfig — BIND picks up dnssec-policy and generates keys itself
rc = subprocess.run(
[rndc_cmd, "reconfig"],
capture_output=True, text=True, timeout=15
)
if rc.returncode == 0:
log.info("rndc reconfig OK — BIND will generate keys and sign the zone")
else:
log.warning(f"rndc reconfig {rc.returncode}: {rc.stderr.strip()}")
# Wait for BIND to generate keys and produce DS records
import time
ds_records = []
for wait_s in [3, 5, 8, 10]:
time.sleep(wait_s)
ds_records = _get_ds_records(zone_name, key_dir, rndc_cmd)
if ds_records:
log.info(f"DS records available after ~{wait_s}s")
break
log.debug(f"Waiting for BIND to generate keys for '{zone_name}'...")
return {
"success": True,
"zone": zone_name,
"action": "enable",
"ds_records": ds_records,
"key_dir": key_dir,
"message": (
f"DNSSEC activé pour '{zone_name}'. "
+ (f"Publiez les {len(ds_records)} enregistrement(s) DS chez votre registrar."
if ds_records
else
"BIND génère les clés — relancez 'Récupérer les DS' dans quelques secondes.")
),
}
except FileNotFoundError as e:
return {"success": False,
"error": f"Commande introuvable : {e}. BIND9 installé et dans le PATH ?"}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout lors de rndc reconfig"}
except Exception as e:
log.error(f"handle_enable_dnssec error for {zone_name}: {e}")
return {"success": False, "error": str(e)}
HANDLERS = {
"apply_zone": handle_apply_zone,
"reload_zone": handle_reload_zone,
@@ -475,6 +790,7 @@ HANDLERS = {
"get_zone_raw": handle_get_zone_raw,
"get_state": handle_get_state,
"save_zone_raw": handle_save_zone_raw,
"enable_dnssec": handle_enable_dnssec,
}
@@ -564,4 +880,4 @@ if __name__ == "__main__":
log.info("DNS agent stopped")
except Exception as e:
log.error(f"Fatal error: {e}")
sys.exit(1)
sys.exit(1)
+227 -196
View File
@@ -128,184 +128,157 @@ def now() -> str:
# Command handlers
# ──────────────────────────────────────────────
def _sync_domain_row(cur, domain_name: str, config: dict, now) -> bool:
"""Insert or update the domain row. Returns True if inserted."""
description = config.get("description", "")
max_accounts = int(config.get("max_accounts", 0))
max_quota_mb = int(config.get("max_quota_mb", 0))
mb_quota_mb = int(config.get("mb_quota_mb", 0))
active = int(config.get("active", True))
cur.execute("SELECT domain FROM domain WHERE domain=%s", (domain_name,))
if cur.fetchone():
cur.execute(
"""UPDATE domain SET
description=%s, max_accounts=%s, max_quota_mb=%s,
mb_quota_mb=%s, active=%s, modified=%s
WHERE domain=%s""",
(description, max_accounts, max_quota_mb, mb_quota_mb,
active, now(), domain_name)
)
log.info(f"Updated domain: {domain_name}")
return False
else:
cur.execute(
"""INSERT INTO domain
(domain, description, max_accounts, max_quota_mb,
mb_quota_mb, active, created, modified)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(domain_name, description, max_accounts, max_quota_mb,
mb_quota_mb, active, now(), now())
)
log.info(f"Inserted domain: {domain_name}")
return True
def _sync_mailboxes(cur, domain_name: str, accounts: list, now):
"""Sync mailbox rows for a domain — insert, update, delete."""
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,))
cur.execute("DELETE FROM alias WHERE address=%s AND goto=%s", (username, username))
log.info(f"Deleted mailbox: {username}")
# Insert or update
for acc in accounts:
username = acc["username"]
password = acc.get("password_hash", "")
name = acc.get("name", "")
acc_active = int(acc.get("active", True))
if username in existing_users:
update_fields = {
"name": name,
"quota_mb": int(acc.get("quota", 1024)),
"active": acc_active,
"modified": now(),
}
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, domain, quota_mb,
active, created, modified)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(username, password, name, domain_name,
int(acc.get("quota", 1024)),
acc_active, now(), now())
)
# Self-alias required for virtual_alias_maps
cur.execute("SELECT address FROM alias WHERE address=%s", (username,))
if not cur.fetchone():
cur.execute(
"""INSERT INTO alias
(address, goto, domain, active, created, modified)
VALUES (%s,%s,%s,1,%s,%s)""",
(username, username, domain_name, now(), now())
)
log.info(f"Created mailbox + self-alias: {username}")
return incoming_users
def _sync_aliases(cur, domain_name: str, aliases: list, incoming_users: set, now):
"""Sync alias rows for a domain — upsert and delete stale entries."""
cur.execute("SELECT address FROM alias WHERE domain=%s", (domain_name,))
existing_aliases = {r["address"] for r in cur.fetchall()}
incoming_alias_addresses = {a["address"] for a in aliases}
# Remove stale aliases (keep self-aliases — managed with mailbox lifecycle)
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
for alias in aliases:
address = alias["address"]
goto = alias["goto"]
alias_active = int(alias.get("active", True))
if address in existing_aliases:
cur.execute(
"UPDATE alias SET goto=%s, active=%s, modified=%s WHERE address=%s",
(goto, alias_active, now(), address)
)
else:
cur.execute(
"""INSERT INTO alias
(address, goto, domain, active, created, modified)
VALUES (%s,%s,%s,%s,%s,%s)""",
(address, goto, domain_name, alias_active, now(), now())
)
log.info(f"Created alias: {address}{goto}")
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"}
]
}
Targets the netadmin_mail schema (not PostfixAdmin).
"""
domain_name = payload["domain"]
dcfg = payload.get("config", {})
domain_name = payload.get("domain")
config = payload.get("config", {})
accounts = payload.get("accounts", [])
aliases = payload.get("aliases", [])
if not domain_name:
return {"success": False, "error": "Missing 'domain' in payload"}
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}")
_sync_domain_row(cur, domain_name, config, now)
incoming_users = _sync_mailboxes(cur, domain_name, accounts, now)
_sync_aliases(cur, domain_name, aliases, incoming_users, now)
conn.commit()
# Postfix reads MySQL directly — reload only needed for domain-level changes
_reload_postfix(cfg)
log.info(f"apply_domain '{domain_name}' complete — "
f"{len(accounts)} mailbox(es), {len(aliases)} alias(es)")
return {"success": True}
except Exception as e:
log.error(f"apply_domain error: {e}")
log.error(f"apply_domain error for '{domain_name}': {e}")
return {"success": False, "error": str(e)}
@@ -339,10 +312,9 @@ def handle_get_quota_usage(payload: dict, cfg: dict) -> dict:
usage = {}
with conn:
with conn.cursor() as cur:
# quota2 is populated by Dovecot dict quota backend
cur.execute(
"SHOW TABLES LIKE 'quota2'"
)
# quota2 is Dovecot's quota backend table — may not exist in netadmin_mail
# If absent, quota usage simply won't be reported (non-fatal)
cur.execute("SHOW TABLES LIKE 'quota2'")
has_quota2 = cur.fetchone() is not None
if has_quota2:
@@ -449,56 +421,38 @@ def handle_get_state(payload: dict, cfg: dict) -> dict:
for dom in domains:
domain_name = dom["domain"]
# Mailboxes — exclude password for security
# Mailboxes — no local_part/maildir in netadmin_mail
cur.execute(
"""SELECT username, local_part, name,
ROUND(quota / 1048576) AS quota_mb,
active
"""SELECT username, name, quota_mb, active
FROM mailbox
WHERE domain=%s
ORDER BY local_part""",
ORDER BY username""",
(domain_name,)
)
mailboxes = cur.fetchall()
# Aliases — exclude self-aliases (address == goto, used for mailboxes)
# Aliases
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
"name": domain_name,
"active": bool(dom["active"]),
"max_accounts": int(dom.get("max_accounts", 0)),
"max_quota_mb": int(dom.get("max_quota_mb", 0)),
"mb_quota_mb": int(dom.get("mb_quota_mb", 0)),
"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"]),
"username": m["username"],
"name": m["name"] or "",
"quota_mb": int(m["quota_mb"] or 0),
"active": bool(m["active"]),
}
for m in mailboxes
],
@@ -517,11 +471,88 @@ def handle_get_state(payload: dict, cfg: dict) -> dict:
log.error(f"get_state error: {e}")
return {"success": False, "error": str(e)}
def handle_generate_dkim(payload: dict, cfg: dict) -> dict:
"""
Generate a DKIM keypair for a domain using rspamadm dkim_keygen.
Writes private key and map config to the configured DKIM directory.
Payload: { "domain": "example.com", "selector": "mail" }
Returns: { "success": true, "selector": "mail", "txt_record": "v=DKIM1; k=rsa; p=..." }
"""
domain = payload["domain"]
selector = payload.get("selector") or cfg.get("dkim_selector", "mail")
dkim_dir = cfg.get("dkim_dir", "/var/lib/rspamd/dkim")
try:
Path(dkim_dir).mkdir(parents=True, exist_ok=True)
key_file = Path(dkim_dir) / f"{selector}.{domain}.key"
txt_file = Path(dkim_dir) / f"{selector}.{domain}.pub.txt"
# Generate keypair
result = subprocess.run(
["rspamadm", "dkim_keygen",
"-s", selector,
"-d", domain,
"-k", str(key_file)],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
log.error(f"dkim_keygen failed for {domain}: {err}")
return {"success": False, "error": f"rspamadm error: {err}"}
# rspamadm writes the TXT record content to stdout
txt_output = result.stdout.strip()
# Save for reference
txt_file.write_text(txt_output)
# Extract just the p= value for the TXT record
# rspamadm outputs: selector._domainkey.domain. IN TXT "v=DKIM1; k=rsa; p=MIIB..."
# (possibly split across multiple quoted strings)
import re
parts = re.findall(r'"([^"]+)"', txt_output)
txt_value = "".join(parts) # join multi-part TXT
if not txt_value:
# Fallback: try to extract from the raw output
m = re.search(r'(v=DKIM1[^"\n]+)', txt_output)
txt_value = m.group(1).strip() if m else txt_output
# Write Rspamd signing config entry
signing_conf = Path(dkim_dir) / "signing.conf"
entry = f'\n# {domain}\n{domain}\t{{\n\tpath = "{key_file}";\n\tselector = "{selector}";\n}}\n'
existing = signing_conf.read_text() if signing_conf.exists() else ""
if domain not in existing:
with open(signing_conf, "a") as f:
f.write(entry)
log.info(f"Added {domain} to {signing_conf}")
log.info(f"DKIM generated for {domain} selector={selector}")
return {
"success": True,
"domain": domain,
"selector": selector,
"txt_record": txt_value,
"key_file": str(key_file),
}
except FileNotFoundError:
return {"success": False, "error": "rspamadm not found — is Rspamd installed?"}
except subprocess.TimeoutExpired:
return {"success": False, "error": "rspamadm timed out"}
except Exception as e:
log.error(f"generate_dkim 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,
"generate_dkim": handle_generate_dkim,
}
+16
View File
@@ -63,6 +63,15 @@ class SyncConfig:
interval: int = 300 # seconds between polls, 0 = disabled
@dataclass
class DnsConfig:
default_ns: list = field(default_factory=lambda: ["ns1.infolix.fr", "ns2.infolix.fr"])
default_soa_admin: str = "hostmaster"
dnssec_enabled: bool = False # whether DNSSEC signing is available on the agent
keygen_cmd: str = "dnssec-keygen"
key_dir: str = "/etc/bind/keys"
@dataclass
class DatabaseConfig:
host: str = "localhost"
@@ -104,6 +113,13 @@ class AppConfig:
redis: RedisConfig = field(default_factory=RedisConfig)
sync: SyncConfig = field(default_factory=SyncConfig)
smtp: SmtpConfig = field(default_factory=SmtpConfig)
dns: DnsConfig = field(default_factory=DnsConfig)
mail_default_aliases: dict = field(default_factory=lambda: {
"abuse": "postmaster@localhost",
"hostmaster": "postmaster@localhost",
"postmaster": "admin@localhost",
"webmaster": "admin@localhost",
})
database: DatabaseConfig = field(default_factory=DatabaseConfig)
mail: MailConfig = field(default_factory=MailConfig)
auth: AuthConfig = field(default_factory=AuthConfig)
+70 -54
View File
@@ -1,7 +1,6 @@
# ═══════════════════════════════════════════════════════════════════
# NetAdmin — fichier de configuration
# Emplacement par défaut : /etc/netadmin/config.yaml
# Surcharge via : NETADMIN_CONFIG=/chemin/config.yaml
# ═══════════════════════════════════════════════════════════════════
# ── Serveur ─────────────────────────────────────────────────────────
@@ -14,44 +13,55 @@ server:
# ── 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)
host: "localhost"
port: 6380
password: ""
db: 0
ack_timeout: 10
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
enabled: true
ca_cert: "/etc/netadmin/certs/ca.crt"
client_cert: "/etc/netadmin/certs/backend.crt"
client_key: "/etc/netadmin/certs/backend.key"
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
enabled: false
host: "smtp.example.com"
port: 587
username: ""
password: ""
from_addr: "netadmin@example.com"
from_name: "NetAdmin"
use_tls: true
use_ssl: false
notify_login: true
notify_role_added: true
# ── Synchronisation agent ───────────────────────────────────────────
# ── Synchronisation ─────────────────────────────────────────────────
sync:
# Intervalle de polling MariaDB → SQLite (secondes, 0 = désactivé)
interval: 300 # 5 minutes
interval: 300
# ── DNS (backend) ────────────────────────────────────────────────────
dns:
default_ns:
- "ns1.infolix.fr."
- "ns2.infolix.fr."
# Email admin SOA par défaut (sans arobase, @ → .)
default_soa_admin: "hostmaster.infolix.fr"
dnssec_enabled: true
keygen_cmd: "dnssec-keygen"
key_dir: "/etc/bind/keys"
# ── Alias mail créés automatiquement à la création d'un domaine mail ───
mail_default_aliases:
abuse: "postmaster@infolix.fr" # RFC 2142 obligatoire
hostmaster: "postmaster@infolix.fr" # gestionnaire DNS
postmaster: "admin@infolix.fr" # RFC 5321 obligatoire
webmaster: "admin@infolix.fr" # gestionnaire web
# ── 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
@@ -59,40 +69,44 @@ database:
password: "changeme"
name: "netadmin"
# ── Mail ─────────────────────────────────────────────────────────────
# ── Authentification ─────────────────────────────────────────────────
session_secret: ""
auth:
session_secret: ""
session_ttl_hours: 8
oidc:
enabled: false
issuer: ""
client_id: ""
client_secret: ""
redirect_uri: "http://localhost:3000/auth/callback"
use_pkce: true
enabled: false
issuer: ""
client_id: ""
client_secret: ""
redirect_uri: "http://localhost:3000/auth/callback"
use_pkce: true
auto_provision: true
admin_group: ""
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
# Configuration des AGENTS
# Ces sections sont lues par dns_agent.py / mail_agent.py uniquement
# ═══════════════════════════════════════════════════════════════════
# ── Agent DNS (serveur BIND) ────────────────────────────────────────
# ── Agent DNS (serveur BIND9) ────────────────────────────────────────
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
# ── DNSSEC ──────────────────────────────────────────────────────
key_dir: "/etc/bind/keys" # répertoire des clés DNSSEC (key-directory BIND9)
dnssec_policy: "default" # nom de la dnssec-policy dans named.conf
# ── Redis ────────────────────────────────────────────────────────
redis_host: "netadmin.example.com"
redis_port: 6380
redis_password: ""
redis_db: 0
tls:
enabled: true
ca_cert: "/etc/netadmin/certs/ca.crt"
@@ -100,25 +114,27 @@ dns_agent:
client_key: "/etc/netadmin/certs/dns-agent.key"
check_hostname: true
# ── Agent Mail (serveur Postfix/Dovecot) ────────────────────────────
# ── Agent Mail (serveur Postfix/Dovecot) ────────────────────────────
mail_agent:
# ── MariaDB (base PostfixAdmin existante) ──────────────────────
# ── MariaDB PostfixAdmin ─────────────────────────────────────────
db_host: "localhost"
db_port: 3306
db_name: "postfix"
db_user: "postfix"
db_password: "mot_de_passe_db"
# ── Postfix ─────────────────────────────────────────────────────
# ── Postfix ─────────────────────────────────────────────────────
postfix_reload_cmd: "postfix reload"
# ── Redis ───────────────────────────────────────────────────────
# ── DKIM (Rspamd sur ce même serveur) ───────────────────────────
dkim_dir: "/var/lib/rspamd/dkim"
dkim_selector: "mail"
# ── 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"
+9
View File
@@ -96,3 +96,12 @@ def _dt(v) -> str | None:
if isinstance(v, (_datetime.datetime, _datetime.date)):
return v.isoformat()
return str(v) if v else None
def get_or_404(conn, query: str, params: tuple, detail: str):
"""Execute query and raise HTTPException(404) if no row found."""
from fastapi import HTTPException
row = conn.execute(query, params).fetchone()
if not row:
raise HTTPException(404, detail)
return row
+1
View File
@@ -9,3 +9,4 @@ qrcode>=7.4.2
pillow>=10.0.0
PyMySQL>=1.1.0
passlib[bcrypt]>=1.7.4
+197 -151
View File
@@ -17,6 +17,21 @@ import hashlib
from config import cfg
import datetime
# ── Constantes de rôles ───────────────────────────────────────────────────
ROLE_GLOBAL_ADMIN = "global_admin"
ROLE_DNS_ADMIN = "dns_admin"
ROLE_MAIL_ADMIN = "mail_admin"
ROLE_DOMAIN_ADMIN = "domain_admin"
ROLE_DNS_ZONE_ADMIN = "dns_zone_admin"
ROLE_MAIL_DOMAIN_ADMIN = "mail_domain_admin"
ALL_ROLES = {
ROLE_GLOBAL_ADMIN, ROLE_DNS_ADMIN, ROLE_MAIL_ADMIN,
ROLE_DOMAIN_ADMIN, ROLE_DNS_ZONE_ADMIN, ROLE_MAIL_DOMAIN_ADMIN,
}
# ─────────────────────────────────────────────────────────────────────────
from contextlib import contextmanager
from pathlib import Path
from typing import List, Optional, Dict
@@ -86,7 +101,7 @@ def _create_session(user_id: str, ip: str = None) -> str:
expires = now + datetime.timedelta(seconds=SESSION_TTL)
with get_db() as conn:
# Clean expired sessions
conn.execute("DELETE FROM sessions WHERE expires_at < %s", (now.isoformat(),))
conn.execute("DELETE FROM sessions WHERE expires_at < NOW()")
conn.execute(
"INSERT INTO sessions(token, user_id, created_at, expires_at, ip) VALUES (%s,%s,%s,%s,%s)",
(token, user_id, now.isoformat(), expires.isoformat(), ip)
@@ -96,13 +111,12 @@ def _create_session(user_id: str, ip: str = None) -> str:
def _get_session_user(token: str) -> Optional[dict]:
now = datetime.datetime.utcnow().isoformat()
with get_db() as conn:
row = conn.execute(
"SELECT s.user_id, s.expires_at, u.username, u.email, u.full_name, u.active, u.auth_method "
"FROM sessions s JOIN users u ON s.user_id = u.id "
"WHERE s.token = %s AND s.expires_at > %s",
(token, now)
"WHERE s.token = %s AND s.expires_at > NOW()",
(token,)
).fetchone()
if not row or not row["active"]:
return None
@@ -121,7 +135,7 @@ def _get_session_user(token: str) -> Optional[dict]:
def _has_role(user: dict, role: str, scope: str = None) -> bool:
for r in user["roles"]:
if r["role"] == "global_admin":
if r["role"] == ROLE_GLOBAL_ADMIN:
return True
if r["role"] == role and (scope is None or r["scope"] == scope or r["scope"] is None):
return True
@@ -170,6 +184,13 @@ def init_db():
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_preferences (
user_id VARCHAR(36) PRIMARY KEY,
prefs TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Migration: add TOTP columns if upgrading
# Note: SQLite ALTER TABLE only accepts nullable columns or columns with
# a literal default. NOT NULL constraints are NOT allowed in ALTER TABLE.
@@ -224,16 +245,16 @@ def get_current_user(
# Also accept token from cookie
token = request.cookies.get("netadmin_session")
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
raise HTTPException(status_code=401, detail="Non authentifié")
user = _get_session_user(token)
if not user:
raise HTTPException(status_code=401, detail="Session expired or invalid")
raise HTTPException(status_code=401, detail="Session expirée ou invalide")
return user
def require_global_admin(user: dict = Depends(get_current_user)):
if not _has_role(user, "global_admin"):
raise HTTPException(status_code=403, detail="Global admin required")
raise HTTPException(status_code=403, detail="Droits administrateur global requis")
return user
@@ -245,9 +266,9 @@ def get_allowed_dns_domains(user: dict) -> list | None:
dns_zone_admin(scope).
"""
for r in user["roles"]:
if r["role"] == "global_admin" and r["scope"] is None:
if r["role"] == ROLE_GLOBAL_ADMIN and r["scope"] is None:
return None
if r["role"] == "dns_admin" and r["scope"] is None:
if r["role"] == ROLE_DNS_ADMIN and r["scope"] is None:
return None
domains = set()
@@ -265,9 +286,9 @@ def get_allowed_mail_domains(user: dict) -> list | None:
mail_domain_admin(scope).
"""
for r in user["roles"]:
if r["role"] == "global_admin" and r["scope"] is None:
if r["role"] == ROLE_GLOBAL_ADMIN and r["scope"] is None:
return None
if r["role"] == "mail_admin" and r["scope"] is None:
if r["role"] == ROLE_MAIL_ADMIN and r["scope"] is None:
return None
domains = set()
@@ -301,14 +322,14 @@ def require_dns_access(domain: str, user: dict):
"""Raise 403 if user has no DNS access to this domain."""
if not can_access_dns_domain(user, domain):
raise HTTPException(status_code=403,
detail=f"DNS access denied for domain '{domain}'")
detail=f"Accès DNS refusé pour le domaine '{domain}'")
def require_mail_access(domain: str, user: dict):
"""Raise 403 if user has no mail access to this domain."""
if not can_access_mail_domain(user, domain):
raise HTTPException(status_code=403,
detail=f"Mail access denied for domain '{domain}'")
detail=f"Accès mail refusé pour le domaine '{domain}'")
# Legacy alias
@@ -401,7 +422,7 @@ def _get_oidc_discovery() -> dict:
with urllib.request.urlopen(url, timeout=10) as r:
_oidc_discovery_cache = _json.loads(r.read())
except Exception as e:
raise HTTPException(status_code=503, detail=f"Cannot reach Keycloak: {e}")
raise HTTPException(status_code=503, detail=f"Impossible de joindre Keycloak : {e}")
return _oidc_discovery_cache
# ──────────────────────────────────────────────
@@ -442,11 +463,11 @@ def login(body: LoginRequest, request: Request, response: Response):
(body.username,)
).fetchone()
if not row:
raise HTTPException(status_code=401, detail="Invalid username or password")
raise HTTPException(status_code=401, detail="Identifiant ou mot de passe incorrect")
if row["auth_method"] == "oidc":
raise HTTPException(status_code=400, detail="This account uses OpenID Connect — use /auth/oidc/login")
raise HTTPException(status_code=400, detail="Ce compte utilise OpenID Connect — utilisez /auth/oidc/login")
if not _verify_password(body.password, row["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid username or password")
raise HTTPException(status_code=401, detail="Identifiant ou mot de passe incorrect")
ip = request.client.host if request.client else None
token = _create_session(row["id"], ip)
@@ -462,8 +483,6 @@ def login(body: LoginRequest, request: Request, response: Response):
audit.record("auth", "login", target=f"user:{row['username']}",
detail={"method": "local"}, success=True,
user={"id": row["id"], "username": row["username"]}, ip=ip)
notify.notify_login(row["username"], row["email"], ip)
# Signal frontend if TOTP verification is required after login
if row["totp_enabled"]:
# TOTP is configured — verify the code before granting access
@@ -484,6 +503,8 @@ def login(body: LoginRequest, request: Request, response: Response):
totp_pending=True,
totp_setup_required=True,
)
# No TOTP — notify login immediately
notify.notify_login(row["username"], row.get("email", ""), ip)
return SessionInfo(
token=token,
expires_at=expires,
@@ -546,168 +567,190 @@ def oidc_config():
}
def _exchange_oidc_token(discovery: dict, code: str, redirect_uri: str,
code_verifier: str | None) -> dict:
"""Exchange an authorization code for tokens via Keycloak token endpoint."""
import urllib.request, urllib.parse, json as _json
token_data: dict = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": OIDC_CLIENT_ID,
}
if OIDC_USE_PKCE and code_verifier:
token_data["code_verifier"] = code_verifier
if OIDC_CLIENT_SECRET:
token_data["client_secret"] = OIDC_CLIENT_SECRET
req = urllib.request.Request(
discovery["token_endpoint"],
data=urllib.parse.urlencode(token_data).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as r:
tokens = _json.loads(r.read())
if "error" in tokens:
raise HTTPException(
status_code=400,
detail=f"Erreur token Keycloak : {tokens['error']}{tokens.get('error_description', '')}"
)
access_token = tokens.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Pas d'access_token dans la réponse Keycloak")
# Fetch userinfo
import urllib.request as _ur
req2 = _ur.Request(
discovery["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
with _ur.urlopen(req2, timeout=10) as r:
import json as _json2
return _json2.loads(r.read())
def _provision_oidc_user(conn, userinfo: dict) -> str:
"""Insert or update user from Keycloak userinfo claims. Returns user_id."""
sub = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email") or sub
email = userinfo.get("email")
full_name = (userinfo.get("name") or
f"{userinfo.get('given_name', '')} {userinfo.get('family_name', '')}".strip()
or username)
groups = userinfo.get("groups", [])
row = conn.execute("SELECT * FROM users WHERE oidc_sub = %s", (sub,)).fetchone()
if not row:
if not cfg.auth.oidc.auto_provision:
raise HTTPException(
status_code=403,
detail="Auto-provisionnement désactivé. Contactez un administrateur."
)
uid = str(uuid.uuid4())
conn.execute(
"INSERT INTO users(id, username, email, full_name, auth_method, oidc_sub) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(uid, username, email, full_name, "oidc", sub)
)
if OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups:
conn.execute(
"INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)",
(str(uuid.uuid4()), uid, ROLE_GLOBAL_ADMIN, None)
)
return uid
else:
user_id = row["id"]
conn.execute(
"UPDATE users SET email=%s, full_name=%s WHERE id=%s",
(email, full_name, user_id)
)
if OIDC_ADMIN_GROUP:
has_role = conn.execute(
"SELECT id FROM user_roles WHERE user_id=%s AND role=%s AND scope IS NULL",
(user_id, ROLE_GLOBAL_ADMIN)
).fetchone()
if OIDC_ADMIN_GROUP in groups and not has_role:
conn.execute(
"INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)",
(str(uuid.uuid4()), user_id, ROLE_GLOBAL_ADMIN, None)
)
elif OIDC_ADMIN_GROUP not in groups and has_role:
conn.execute(
"DELETE FROM user_roles WHERE user_id=%s AND role=%s AND scope IS NULL",
(user_id, ROLE_GLOBAL_ADMIN)
)
return user_id
@router.post("/oidc/callback")
def oidc_callback(body: dict, request: Request, response: Response):
"""Exchange authorization code for a NetAdmin session.
Expected body:
{
"code": "<authorization code from Keycloak>",
"redirect_uri": "<must match exactly what was sent in the auth request>",
"code_verifier": "<PKCE verifier — required if use_pkce=true>"
}
"""
"""Exchange authorization code for a NetAdmin session."""
if not OIDC_ENABLED:
raise HTTPException(status_code=400, detail="OpenID Connect is not enabled")
raise HTTPException(status_code=400, detail="OpenID Connect non activé")
code = body.get("code")
redirect_uri = body.get("redirect_uri", OIDC_REDIRECT_URI)
code_verifier = body.get("code_verifier")
if not code:
raise HTTPException(status_code=422, detail="Missing 'code' in request body")
raise HTTPException(status_code=422, detail="Paramètre 'code' manquant")
if OIDC_USE_PKCE and not code_verifier:
raise HTTPException(status_code=422, detail="PKCE is enabled'code_verifier' is required")
import urllib.request, urllib.parse, json as _json, base64 as _b64
raise HTTPException(status_code=422, detail="PKCE activé'code_verifier' requis")
try:
discovery = _get_oidc_discovery()
# ── Token exchange ──────────────────────────────────────────
token_data: dict = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": OIDC_CLIENT_ID,
}
if OIDC_USE_PKCE and code_verifier:
token_data["code_verifier"] = code_verifier
headers = {"Content-Type": "application/x-www-form-urlencoded"}
# Keycloak accepts client_secret in POST body (confidential clients)
# or without it (public clients with PKCE)
if OIDC_CLIENT_SECRET:
token_data["client_secret"] = OIDC_CLIENT_SECRET
req = urllib.request.Request(
discovery["token_endpoint"],
data=urllib.parse.urlencode(token_data).encode(),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as r:
tokens = _json.loads(r.read())
if "error" in tokens:
raise HTTPException(
status_code=400,
detail=f"Keycloak token error: {tokens['error']}{tokens.get('error_description', '')}"
)
access_token = tokens.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="No access_token in Keycloak response")
# ── Userinfo ────────────────────────────────────────────────
req2 = urllib.request.Request(
discovery["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(req2, timeout=10) as r:
userinfo = _json.loads(r.read())
userinfo = _exchange_oidc_token(discovery, code, redirect_uri, code_verifier)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"Keycloak OIDC error: {e}")
raise HTTPException(status_code=400, detail=f"Erreur OIDC Keycloak : {e}")
# ── Extract user info from Keycloak claims ───────────────────────
sub = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email") or sub
email = userinfo.get("email")
if not sub:
raise HTTPException(status_code=400, detail="No 'sub' claim in userinfo")
raise HTTPException(status_code=400, detail="Pas de claim 'sub' dans userinfo")
# Keycloak uses preferred_username; fallback to email then sub
username = userinfo.get("preferred_username") or userinfo.get("email") or sub
email = userinfo.get("email")
# Keycloak may provide name, or given_name + family_name
full_name = userinfo.get("name") or (
f"{userinfo.get('given_name', '')} {userinfo.get('family_name', '')}".strip()
) or username
# Keycloak groups claim (requires "groups" mapper in client scope)
groups = userinfo.get("groups", [])
# ── Provision / update user in DB ───────────────────────────────
with get_db() as conn:
row = conn.execute("SELECT * FROM users WHERE oidc_sub = %s", (sub,)).fetchone()
user_id = _provision_oidc_user(conn, userinfo)
if not row:
if not cfg.auth.oidc.auto_provision:
raise HTTPException(
status_code=403,
detail="Auto-provisioning is disabled. Ask an admin to create your account."
)
uid = str(uuid.uuid4())
conn.execute(
"INSERT INTO users(id, username, email, full_name, auth_method, oidc_sub) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(uid, username, email, full_name, "oidc", sub)
)
user_id = uid
# Auto-grant global_admin if user is in the configured admin group
if OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups:
conn.execute(
"INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)",
(str(uuid.uuid4()), uid, "global_admin", None)
)
else:
user_id = row["id"]
# Keep email/name in sync with Keycloak on every login
conn.execute(
"UPDATE users SET email=%s, full_name=%s WHERE id=%s",
(email, full_name, user_id)
)
# Sync admin group membership
if OIDC_ADMIN_GROUP:
has_role = conn.execute(
"SELECT id FROM user_roles WHERE user_id=%s AND role='global_admin' AND scope IS NULL",
(user_id,)
).fetchone()
if OIDC_ADMIN_GROUP in groups and not has_role:
conn.execute(
"INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)",
(str(uuid.uuid4()), user_id, "global_admin", None)
)
elif OIDC_ADMIN_GROUP not in groups and has_role:
conn.execute(
"DELETE FROM user_roles WHERE user_id=%s AND role='global_admin' AND scope IS NULL",
(user_id,)
)
ip = request.client.host if request.client else None
token = _create_session(user_id, ip)
ip = request.client.host if request.client else ""
token = _create_session(user_id, ip)
expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat()
response.set_cookie("netadmin_session", token, httponly=True, samesite="lax", max_age=SESSION_TTL)
ip2 = request.client.host if request.client else ""
audit.record("auth", "login", target=f"user:{username}",
detail={"method": "oidc", "sub": sub}, success=True,
user={"id": user_id, "username": username}, ip=ip2)
notify.notify_login(username, email, ip2)
user={"id": user_id, "username": username}, ip=ip)
notify.notify_login(username, email or "", ip)
# Return full session like the local login endpoint
with get_db() as conn:
row2 = conn.execute("SELECT * FROM users WHERE id=%s", (user_id,)).fetchone()
roles = conn.execute("SELECT id, role, scope FROM user_roles WHERE user_id=%s", (user_id,)).fetchall()
row = conn.execute("SELECT * FROM users WHERE id=%s", (user_id,)).fetchone()
roles = conn.execute(
"SELECT id, role, scope FROM user_roles WHERE user_id=%s", (user_id,)
).fetchall()
return SessionInfo(
token=token,
expires_at=expires,
user=_user_response(row2,
token=token, expires_at=expires,
user=_user_response(row,
roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles],
),
)
@router.get("/me/preferences")
def get_preferences(user: dict = Depends(get_current_user)):
"""Return the current user's saved preferences (theme, etc.)."""
with get_db() as conn:
row = conn.execute(
"SELECT prefs FROM user_preferences WHERE user_id = %s", (user["id"],)
).fetchone()
if not row:
return {}
import json as _json
try:
return _json.loads(row["prefs"])
except Exception:
return {}
@router.put("/me/preferences", status_code=204)
def save_preferences(body: dict, user: dict = Depends(get_current_user)):
"""Save arbitrary user preferences (theme, etc.) to the database."""
import json as _json
# Whitelist allowed keys to avoid storing arbitrary data
allowed = {"theme"}
filtered = {k: v for k, v in body.items() if k in allowed}
prefs_json = _json.dumps(filtered)
with get_db() as conn:
conn.execute(
"INSERT INTO user_preferences(user_id, prefs) VALUES(%s, %s) "
"ON DUPLICATE KEY UPDATE prefs=%s",
(user["id"], prefs_json, prefs_json)
)
# ──────────────────────────────────────────────
# User management (global_admin only)
@@ -731,7 +774,7 @@ def list_users(admin=Depends(require_global_admin)):
@router.post("/users", response_model=UserResponse, status_code=201)
def create_user(body: UserCreate, admin=Depends(require_global_admin)):
if body.auth_method == "local" and not body.password:
raise HTTPException(status_code=422, detail="Password required for local accounts")
raise HTTPException(status_code=422, detail="Mot de passe requis pour les comptes locaux")
uid = str(uuid.uuid4())
pw_hash = _hash_password(body.password) if body.password else None
with get_db() as conn:
@@ -742,7 +785,7 @@ def create_user(body: UserCreate, admin=Depends(require_global_admin)):
(uid, body.username, pw_hash, body.email, body.full_name, body.auth_method, body.oidc_sub)
)
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail=f"Username '{body.username}' already exists")
raise HTTPException(status_code=409, detail=f"L'utilisateur '{body.username}' existe déjà")
row = conn.execute("SELECT * FROM users WHERE id = %s", (uid,)).fetchone()
audit.record("auth", "create_user", target=f"user:{body.username}",
detail={"auth_method": body.auth_method, "email": body.email},
@@ -996,8 +1039,11 @@ def totp_validate_login(body: dict, request: Request, response: Response):
user=user_data, ip=request.client.host if request.client else "")
raise HTTPException(400, "Code TOTP invalide")
ip = request.client.host if request.client else ""
audit.record("auth", "totp_verified", target=f"user:{row['username']}",
user=user_data, ip=request.client.host if request.client else "")
user=user_data, ip=ip)
# Notify login only now that TOTP is validated
notify.notify_login(row["username"], row.get("email", ""), ip)
expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat()
return SessionInfo(
token=token, expires_at=expires,
+247 -131
View File
@@ -31,7 +31,7 @@ from main import get_request_ip
router = APIRouter(dependencies=[Depends(get_current_user)])
from db import get_db, _dt
from db import get_db, _dt, get_or_404
VALID_RECORD_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"}
@@ -113,20 +113,21 @@ except Exception as e:
# ──────────────────────────────────────────────
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)
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)
enable_dnssec: bool = Field(False, description="Sign zone with DNSSEC after creation")
@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")
raise ValueError("L'email admin ne doit pas contenir '@' — utilisez un point")
return v
@field_validator("name")
@@ -134,19 +135,19 @@ class ZoneCreate(BaseModel):
def validate_zone_name(cls, v: str) -> str:
v = v.strip().rstrip(".")
if not v:
raise ValueError("Zone name cannot be empty")
raise ValueError("Le nom de zone ne peut pas être vide")
if len(v) > 253:
raise ValueError("Zone name too long (max 253 characters)")
raise ValueError("Nom de zone trop long (max 253 caractères)")
labels = v.split(".")
if len(labels) < 2:
raise ValueError("Zone name must have at least two labels (e.g. example.com)")
raise ValueError("Le nom de zone doit avoir au moins deux labels (ex: example.com)")
for label in labels:
if not label:
raise ValueError(f"Empty label in zone name '{v}'")
raise ValueError(f"Label vide dans le nom de zone '{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}'")
raise ValueError(f"Label invalide '{label}'")
return v
@@ -162,7 +163,7 @@ class ZoneConfig(BaseModel):
@classmethod
def no_at(cls, v: str) -> str:
if "@" in v:
raise ValueError("Admin email must not contain '@'")
raise ValueError("L'email admin ne doit pas contenir '@'")
return v.strip()
@@ -179,6 +180,7 @@ class ZoneInfo(BaseModel):
last_applied_at: Optional[str] = None
queued: bool = False
queue_id: Optional[str] = None
dnssec_enabled: Optional[bool] = None # None = unknown
@field_validator("last_applied_at", mode="before")
@classmethod
@@ -199,7 +201,7 @@ class RecordCreate(BaseModel):
def validate_type(cls, v: str) -> str:
v = v.strip().upper()
if v not in VALID_RECORD_TYPES:
raise ValueError(f"Unsupported type '{v}'")
raise ValueError(f"Type non supporté '{v}'")
return v
@field_validator("name")
@@ -209,14 +211,14 @@ class RecordCreate(BaseModel):
if not v:
return "@"
if not RE_DNS_NAME.match(v):
raise ValueError(f"Invalid DNS name '{v}'")
raise ValueError(f"Nom DNS invalide '{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")
raise ValueError("La valeur ne peut pas être vide")
return v.strip()
@model_validator(mode="after")
@@ -224,49 +226,49 @@ class RecordCreate(BaseModel):
t, v = self.type, self.value
if t == "A":
try: ipaddress.IPv4Address(v)
except ValueError: raise ValueError(f"Invalid IPv4: '{v}'")
except ValueError: raise ValueError(f"IPv4 invalide : '{v}'")
elif t == "AAAA":
try: ipaddress.IPv6Address(v)
except ValueError: raise ValueError(f"Invalid IPv6: '{v}'")
except ValueError: raise ValueError(f"IPv6 invalide : '{v}'")
elif t in ("CNAME", "NS", "PTR"):
if not RE_DNS_NAME.match(v.rstrip(".")):
raise ValueError(f"Invalid hostname for {t}: '{v}'")
raise ValueError(f"Nom d'hôte invalide pour {t}: '{v}'")
elif t == "MX":
if self.priority is None:
raise ValueError("MX requires a priority")
raise ValueError("MX : une priorité est requise")
if not RE_DNS_NAME.match(v.rstrip(".")):
raise ValueError(f"Invalid MX hostname: '{v}'")
raise ValueError(f"Nom d'hôte MX invalide : '{v}'")
elif t == "SRV":
if self.priority is None:
raise ValueError("SRV requires a priority")
raise ValueError("SRV : une priorité est requise")
parts = v.split()
if len(parts) != 3:
raise ValueError("SRV: '<weight> <port> <target>'")
raise ValueError("SRV : format attendu '<poids> <port> <cible>'")
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")
raise ValueError("SRV : poids/port doit être entre 0 et 65535")
elif t == "TXT":
if "\n" in v or "\r" in v:
raise ValueError("TXT must not contain newlines")
raise ValueError("TXT ne doit pas contenir de retours à la ligne")
elif t == "TLSA":
parts = v.split(None, 3)
if len(parts) != 4:
raise ValueError("TLSA: '<usage> <selector> <matching-type> <cert-hex>'")
raise ValueError("TLSA : format '<usage> <sélecteur> <type> <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")
raise ValueError("TLSA : usage/sélecteur/type doivent être des entiers")
if usage not in range(4): raise ValueError("TLSA : usage doit être entre 0 et 3")
if sel not in (0, 1): raise ValueError("TLSA : sélecteur doit être 0 ou 1")
if mt not in (0, 1, 2): raise ValueError("TLSA : type de correspondance doit être entre 0 et 2")
if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")):
raise ValueError("TLSA cert data must be hex")
raise ValueError("TLSA : données certificat en hexadécimal")
elif t == "DS":
parts = v.split(None, 3)
if len(parts) != 4:
raise ValueError("DS: '<keytag> <algo> <digest-type> <digest-hex>'")
raise ValueError("DS : format '<keytag> <algo> <type-digest> <hex>'")
try:
kt, algo, dt = int(parts[0]), int(parts[1]), int(parts[2])
except ValueError:
@@ -278,14 +280,14 @@ class RecordCreate(BaseModel):
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>'")
if len(parts) != 3: raise ValueError("CAA : format '<flags> <tag> <valeur>'")
try:
flags = int(parts[0])
assert 0 <= flags <= 255
except (ValueError, AssertionError):
raise ValueError("CAA flags 0-255")
raise ValueError("CAA : flags doit être entre 0 et 255")
if parts[1] not in ("issue", "issuewild", "iodef"):
raise ValueError("CAA tag: issue, issuewild or iodef")
raise ValueError("CAA : tag doit être issue, issuewild ou iodef")
return self
@@ -384,6 +386,18 @@ def _row_to_zone(row, record_count: int = 0) -> ZoneInfo:
@router.get("/config")
def get_dns_config():
"""Return public DNS configuration needed by the frontend.
Currently exposes the default NS records added to new zones.
"""
return {
"default_ns": cfg.dns.default_ns or [],
"dnssec_enabled": cfg.dns.dnssec_enabled,
"default_soa_admin": getattr(cfg.dns, "default_soa_admin", "hostmaster"),
}
@router.get("/zones", response_model=List[ZoneInfo])
def list_zones(user: dict = Depends(get_current_user)):
allowed = get_allowed_dns_domains(user)
@@ -403,6 +417,7 @@ def list_zones(user: dict = Depends(get_current_user)):
@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:
@@ -411,15 +426,53 @@ def create_zone(body: ZoneCreate, user: dict = Depends(get_current_user)):
"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)
)
# Insert default NS records from config
ns_list = cfg.dns.default_ns or []
for ns in ns_list:
ns_val = ns.rstrip(".")
rec_id = __import__("hashlib").sha1(
f"{body.name}:@:NS:{ns_val}".encode()
).hexdigest()[:36]
conn.execute(
"INSERT IGNORE INTO records(id,zone,name,type,ttl,value,active,pending_sync) "
"VALUES(%s,%s,'@','NS',%s,%s,1,1)",
(rec_id, body.name, body.ttl, ns_val)
)
# Apply zone (outside get_db to avoid lock contention)
q = _apply_zone(body.name)
# DNSSEC signing if requested and enabled in config
dnssec_result = None
if body.enable_dnssec and cfg.dns.dnssec_enabled:
try:
dnssec_ack = publish_dns("enable_dnssec", {
"zone": body.name,
"keygen_cmd": cfg.dns.keygen_cmd,
"key_dir": cfg.dns.key_dir,
})
dnssec_result = dnssec_ack
except Exception as e:
dnssec_result = {"success": False, "error": str(e)}
elif body.enable_dnssec and not cfg.dns.dnssec_enabled:
dnssec_result = {"success": False, "error": "DNSSEC non activé dans la configuration (dns.dnssec_enabled: false)"}
with get_db() as conn:
row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone()
row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone()
count = conn.execute("SELECT COUNT(*) AS n FROM records WHERE zone=%s", (body.name,)).fetchone()["n"]
audit.record("dns", "create_zone", target=f"zone:{body.name}",
detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]},
detail={"admin": body.admin, "ttl": body.ttl,
"ns_added": len(ns_list), "dnssec": body.enable_dnssec,
"queued": q["queued"]},
user=user, ip=get_request_ip())
z = _row_to_zone(row, 0)
z = _row_to_zone(row, count)
z.queued = q["queued"]
z.queue_id = q.get("queue_id")
# Attach DNSSEC result as extra field if applicable
if dnssec_result is not None:
z.__dict__["dnssec"] = dnssec_result
return z
@@ -521,7 +574,7 @@ def update_record(zone_name: str, record_id: str, body: RecordCreate,
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")
raise HTTPException(404, "Enregistrement introuvable")
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,
@@ -546,7 +599,7 @@ def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_curre
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")
raise HTTPException(404, "Enregistrement introuvable")
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}",
@@ -556,16 +609,35 @@ def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_curre
@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."""
"""Force rndc reload on the zone.
Tries reload_zone action first; falls back to apply_zone (also triggers rndc reload)
for compatibility with older agent versions that don't support reload_zone.
"""
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})
# Try reload_zone first (lightweight — no file rewrite)
# Falls back to apply_zone for old agents that don't have reload_zone handler
try:
from broker import publish_and_wait
ack = publish_and_wait("dns.commands", "dns.acks", "reload_zone", {"zone": zone_name})
if ack.get("success"):
audit.record("dns", "reload_zone", target=f"zone:{zone_name}",
detail={"method": "reload_zone"},
user=user, ip=get_request_ip())
return {"zone": zone_name, "queued": False, "queue_id": None}
# Agent returned success=False (unknown action etc.) — fall through
except (RuntimeError, HTTPException):
pass # Agent unreachable or returned error — fall through to apply_zone
# Fallback: apply_zone rewrites the file AND calls rndc reload
result = _apply_zone(zone_name)
audit.record("dns", "reload_zone", target=f"zone:{zone_name}",
detail={"queued": result.get("queued")},
detail={"method": "apply_zone_fallback", "queued": result.get("queued")},
user=user, ip=get_request_ip())
return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("id")}
return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("queue_id")}
# ── Raw zone (read-only — agent generates it) ──────────────────────
@@ -577,7 +649,7 @@ def get_zone_raw(zone_name: str, user: dict = Depends(get_current_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','')}")
raise HTTPException(500, f"Erreur de l'agent DNS : {ack.get('error','')}")
return {"zone": zone_name, "content": ack.get("content", ""), "path": ack.get("path", "")}
except RuntimeError as e:
raise HTTPException(503, str(e))
@@ -593,7 +665,7 @@ def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_u
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','')}")
raise HTTPException(500, f"Erreur de l'agent DNS : {ack.get('error','')}")
return {"zone": zone_name, "content": ack.get("content", content)}
except RuntimeError as e:
raise HTTPException(503, str(e))
@@ -603,6 +675,80 @@ def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_u
# Sync from BIND agent (zone files are source of truth)
# ──────────────────────────────────────────────
def _stable_id(zone_name: str, rname: str, rtype: str, rvalue: str) -> str:
"""Deterministic record ID = sha1(zone:name:type:value) formatted as UUID."""
key = f"{zone_name}:{rname}:{rtype}:{rvalue}".encode()
h = hashlib.sha1(key).hexdigest()
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
def _sync_zone_from_state(conn, zone: dict) -> tuple[bool, int]:
"""Upsert one zone + its records from agent get_state.
Returns (is_new_zone, new_record_count).
"""
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)
)
is_new = False
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)
)
is_new = True
incoming_ids = set()
new_records = 0
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)
if conn.execute("SELECT id FROM records WHERE id=%s", (rec_id,)).fetchone():
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"))
)
new_records += 1
# Remove stale records
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,))
return is_new, new_records
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.
@@ -643,89 +789,10 @@ def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") ->
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,))
is_new, n_rec = _sync_zone_from_state(conn, zone)
imported_zones += 1 if is_new else 0
updated_zones += 0 if is_new else 1
imported_records += n_rec
result = {
"imported_zones": imported_zones,
@@ -744,6 +811,55 @@ def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") ->
return result
@router.post("/zones/{zone_name}/dnssec")
def zone_dnssec(
zone_name: str,
action: str = "enable",
dnssec_policy: str = "default",
user: dict = Depends(get_current_user),
):
"""
Enable or disable DNSSEC for a zone.
Actions: enable | disable | get_ds
Modifies the zone block in named.conf.local to add/remove
dnssec-policy + inline-signing, then calls rndc reconfig.
Returns DS records for publication at the registrar.
"""
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}' introuvable")
payload = {
"zone": zone_name,
"action": action,
"dnssec_policy": dnssec_policy,
# key_dir intentionally omitted — read from agent's own config
}
try:
ack = publish_dns("enable_dnssec", payload)
except Exception as e:
raise HTTPException(502, f"Agent inaccessible : {e}")
if not ack.get("success"):
raise HTTPException(500, ack.get("error", "Erreur agent DNSSEC"))
audit.record("dns", f"dnssec_{action}", target=f"zone:{zone_name}",
detail={"ds_count": len(ack.get("ds_records", []))},
user=user, ip=get_request_ip())
return {
"zone": zone_name,
"action": action,
"ds_records": ack.get("ds_records", []),
"key_dir": ack.get("key_dir", cfg.dns.key_dir),
"message": ack.get("message", ""),
"already_configured": ack.get("already_configured", False),
}
@router.get("/sync/history")
def get_sync_history(
limit: int = 50,
+414 -341
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
-- Correction des alias dans la base netadmin dont source est une partie locale
-- Exemple: "kg-lbc" → "kg-lbc@infolix.fr"
-- Vérifier d'abord
SELECT source, domain FROM aliases WHERE source NOT LIKE '%@%' LIMIT 20;
-- Corriger
UPDATE aliases
SET source = CONCAT(source, '@', domain)
WHERE source NOT LIKE '%@%';
-- Vérifier
SELECT source, domain FROM aliases WHERE source NOT LIKE '%@%';
+18
View File
@@ -0,0 +1,18 @@
-- Correction des comptes avec double domaine dans netadmin_mail
-- Exemple: "asterisk@infolix.fr@infolix.fr" → "asterisk@infolix.fr"
-- Exécuter sur la base netadmin_mail
-- Vérifier d'abord
SELECT username FROM mailbox WHERE username LIKE '%@%@%';
-- Corriger
UPDATE mailbox
SET username = CONCAT(
SUBSTRING_INDEX(username, '@', 1), -- partie locale (avant premier @)
'@',
SUBSTRING_INDEX(username, '@', -1) -- domaine (après dernier @)
)
WHERE username LIKE '%@%@%';
-- Vérifier le résultat
SELECT username FROM mailbox WHERE username LIKE '%@%@%';
+4 -2
View File
@@ -10,10 +10,12 @@
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"@fontsource/jetbrains-mono": "^5.0.0",
"@fontsource-variable/syne": "^5.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.0"
}
}
}
+138 -55
View File
@@ -1,7 +1,10 @@
@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');
/* Fonts imported locally via @fontsource in main.jsx */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ══════════════════════════════════════════════════════════
VARIABLES — thème sombre (identique à l'original)
══════════════════════════════════════════════════════════ */
:root {
--bg-0: #050708;
--bg-1: #0b0e11;
@@ -24,25 +27,58 @@
--yellow: #ffb700;
--yellow-dim: rgba(255, 183, 0, 0.12);
--orange: #ff7d40;
--modal-bg: rgba(0, 0, 0, 0.7);
--scrollbar-thumb: #1e252c;
--shadow-modal: 0 24px 80px rgba(0, 0, 0, 0.6);
--sidebar-w: 240px;
--radius: 6px;
--font-mono: 'JetBrains Mono', monospace;
--font-display: 'Syne', sans-serif;
--font-display: 'Syne Variable', 'Syne', sans-serif;
}
html, body, #root { height: 100%; }
/* ══════════════════════════════════════════════════════════
VARIABLES — thème clair (seulement les overrides)
══════════════════════════════════════════════════════════ */
[data-theme="light"] {
--bg-0: #f0f2f5;
--bg-1: #ffffff;
--bg-2: #f7f8fa;
--bg-3: #eef0f3;
--bg-4: #e4e7ec;
--border: #d8dde5;
--border-hi: #b8c0cc;
--text-0: #0f1923;
--text-1: #2d3f50;
--text-2: #637280;
--text-3: #9aaab8;
--accent: #0080cc;
--accent-dim: rgba(0, 128, 204, 0.1);
--accent-glow: rgba(0, 128, 204, 0.2);
--green: #00965a;
--green-dim: rgba(0, 150, 90, 0.1);
--red: #d42050;
--red-dim: rgba(212, 32, 80, 0.1);
--yellow: #c07800;
--yellow-dim: rgba(192, 120, 0, 0.1);
--orange: #c05020;
--modal-bg: rgba(0, 0, 0, 0.45);
--scrollbar-thumb: #d0d5de;
--shadow-modal: 0 16px 60px rgba(0, 0, 0, 0.18);
}
html, body, #root { height: 100%; width: 100%; }
body {
background: var(--bg-0);
color: var(--text-0);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
/* ── APP LAYOUT ── */
.app { display: flex; height: 100vh; overflow: hidden; }
/* ── APP LAYOUT — plein écran ── */
.app { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
/* ── SIDEBAR ── */
.sidebar {
@@ -55,6 +91,7 @@ body {
padding: 24px 0;
position: relative;
z-index: 10;
flex-shrink: 0;
}
.sidebar::after {
@@ -97,7 +134,7 @@ body {
text-transform: uppercase;
}
.sidebar-nav { flex: 1; padding: 0 10px; display: flex; flex-direction: column; gap: 2px; }
.sidebar-nav { flex: 1; padding: 0 10px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
.nav-item {
width: 100%;
@@ -110,7 +147,7 @@ body {
border-radius: var(--radius);
color: var(--text-2);
font-family: var(--font-mono);
font-size: 12px;
font-size: 13px;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
@@ -126,7 +163,7 @@ body {
border: 1px solid rgba(0,212,255,0.15);
}
.nav-icon { font-size: 14px; width: 18px; text-align: center; }
.nav-icon { font-size: 16px; width: 20px; text-align: center; }
.nav-indicator {
position: absolute;
@@ -164,7 +201,7 @@ body {
.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 { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot--green { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2s infinite; }
.dot--red { background: var(--red); }
@@ -173,9 +210,9 @@ body {
50% { opacity: 0.5; }
}
/* ── MAIN AREA ── */
.main { flex: 1; overflow-y: auto; background: var(--bg-0); }
.main-inner { padding: 32px; max-width: 1200px; }
/* ── MAIN AREA — plein écran ── */
.main { flex: 1; min-width: 0; overflow-y: auto; background: var(--bg-0); }
.main-inner { padding: 32px; width: 100%; max-width: 100%; }
/* ── PAGE HEADER ── */
.page-header { margin-bottom: 28px; }
@@ -189,7 +226,7 @@ body {
gap: 10px;
}
.page-title .icon { color: var(--accent); }
.page-sub { color: var(--text-2); font-size: 12px; margin-top: 4px; }
.page-sub { color: var(--text-2); font-size: 13px; margin-top: 4px; }
/* ── CARDS ── */
.card {
@@ -209,7 +246,7 @@ body {
.card-title {
font-family: var(--font-display);
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: var(--text-0);
display: flex;
@@ -231,7 +268,7 @@ body {
padding: 7px 12px;
color: var(--text-0);
font-family: var(--font-mono);
font-size: 12px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
@@ -243,10 +280,10 @@ body {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
padding: 8px 16px;
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 12px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
@@ -254,50 +291,64 @@ body {
white-space: nowrap;
letter-spacing: 0.02em;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: var(--bg-0);
}
.btn-primary:hover { background: #33ddff; box-shadow: 0 0 12px var(--accent-glow); }
.btn-primary:hover:not(:disabled) { 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-secondary:hover:not(:disabled) { 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-danger:hover:not(:disabled) { background: rgba(255,74,106,0.2); }
.btn-ghost {
background: transparent;
color: var(--text-2);
padding: 4px 8px;
padding: 5px 5px;
font-size: 25px;
line-height: 1;
}
.btn-ghost:hover { color: var(--text-0); }
.btn-ghost:hover:not(:disabled) { color: var(--text-0); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-icon {
background: transparent;
border: none;
padding: 5px 5px;
font-size: 25px;
line-height: 1;
color: var(--text-2);
cursor: pointer;
border-radius: var(--radius);
transition: color 0.15s;
}
.btn-icon:hover:not(:disabled) { color: var(--text-0); }
.btn-icon:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 5px 5px; font-size: 16px; }
/* ── TABLE ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
thead tr {
border-bottom: 1px solid var(--border);
}
thead tr { border-bottom: 1px solid var(--border); }
th {
padding: 8px 12px;
padding: 10px 14px;
text-align: left;
font-size: 10px;
letter-spacing: 0.1em;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
font-weight: 500;
@@ -305,20 +356,19 @@ th {
}
td {
padding: 10px 12px;
padding: 12px 14px;
border-bottom: 1px solid rgba(31,42,51,0.6);
color: var(--text-1);
font-size: 12px;
font-size: 13px;
}
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; }
.cell-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; }
/* ── BADGES ── */
.badge {
@@ -326,9 +376,9 @@ tbody tr:hover { background: var(--bg-2); }
align-items: center;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -348,7 +398,7 @@ tbody tr:hover { background: var(--bg-2); }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
background: var(--modal-bg);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
@@ -368,7 +418,7 @@ tbody tr:hover { background: var(--bg-2); }
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
box-shadow: var(--shadow-modal);
}
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@@ -383,7 +433,7 @@ tbody tr:hover { background: var(--bg-2); }
.modal-title {
font-family: var(--font-display);
font-size: 15px;
font-size: 16px;
font-weight: 700;
color: var(--text-0);
}
@@ -392,9 +442,9 @@ tbody tr:hover { background: var(--bg-2); }
background: none;
border: none;
color: var(--text-2);
font-size: 18px;
font-size: 20px;
cursor: pointer;
padding: 2px 6px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.1s;
}
@@ -406,16 +456,16 @@ tbody tr:hover { background: var(--bg-2); }
/* ── 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-label { font-size: 11px; letter-spacing: 0.08em; 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;
padding: 9px 13px;
color: var(--text-0);
font-family: var(--font-mono);
font-size: 12px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
width: 100%;
@@ -441,11 +491,7 @@ tbody tr:hover { background: var(--bg-2); }
.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-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); }
@@ -453,7 +499,7 @@ tbody tr:hover { background: var(--bg-2); }
.alert {
padding: 10px 14px;
border-radius: var(--radius);
font-size: 12px;
font-size: 13px;
border-left: 3px solid;
margin-bottom: 12px;
}
@@ -467,15 +513,52 @@ tbody tr:hover { background: var(--bg-2); }
.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); }
.quota-text { font-size: 11px; 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 { background: var(--scrollbar-thumb); 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; }
.loading { display: flex; align-items: center; gap: 10px; padding: 20px; color: var(--text-2); font-size: 13px; }
.spinner { width: 16px; height: 16px; 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); } }
/* ── THEME TOGGLE ── */
.theme-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-2);
}
.theme-switch { position: relative; width: 38px; height: 20px; flex-shrink: 0; }
.theme-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.theme-switch-track {
position: absolute;
inset: 0;
background: var(--bg-4);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.theme-switch input:checked + .theme-switch-track { background: var(--accent-dim); border-color: var(--accent); }
.theme-switch-thumb {
position: absolute;
top: 3px; left: 3px;
width: 12px; height: 12px;
background: var(--text-3);
border-radius: 50%;
transition: transform 0.2s, background 0.2s;
pointer-events: none;
}
.theme-switch input:checked ~ .theme-switch-thumb { transform: translateX(18px); background: var(--accent); }
/* ── THEME TRANSITIONS ── */
body, .sidebar, .main, .card, .modal, .nav-item,
.form-input, .form-select, .form-textarea, .btn-secondary {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
+41 -20
View File
@@ -6,7 +6,6 @@ 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";
@@ -14,24 +13,35 @@ 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"] },
{ id:"profile", label:"Mon compte", icon:"◎", roles:[] }, // always visible
];
function hasAccess(user, roles) {
if (!user) return false;
if (roles.length === 0) return true; // toujours visible (ex: Mon compte)
return user.roles?.some(r => r.role === "global_admin" || roles.includes(r.role));
}
// Theme persistence
const getInitialTheme = () => {
try { return localStorage.getItem("netadmin_theme") || "dark"; } catch { return "dark"; }
};
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);
const [theme, setTheme] = useState(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem("netadmin_theme", theme); } catch {}
}, [theme]);
// Persist token
useEffect(() => {
@@ -67,19 +77,32 @@ export default function App() {
if (!r.ok) throw new Error("Erreur serveur");
return r.json();
})
.then(u => { if (u) setUser(u); })
.then(u => { if (u) { setUser(u); loadPreferences(token); } })
.catch(() => doLogout(false))
.finally(() => setChecking(false));
}, [token, apiBase]); // eslint-disable-line
const onUnauthorized = useCallback(() => doLogout(false), [doLogout]);
const loadPreferences = useCallback(async (tok) => {
try {
const r = await fetch(`${apiBase}/auth/me/preferences`, {
headers: { Authorization: `Bearer ${tok}` }
});
if (r.ok) {
const prefs = await r.json();
if (prefs.theme) setTheme(prefs.theme);
}
} catch {}
}, [apiBase]);
// Called by LoginPage once login + TOTP (if needed) are fully complete
const onLogin = useCallback((token, user) => {
const onLogin = useCallback((token, tok) => {
setToken(token);
setUser(user);
setUser(tok);
setActive("domains");
}, []);
loadPreferences(token);
}, [loadPreferences]);
if (checking) {
return (
@@ -142,19 +165,18 @@ export default function App() {
Déconnexion
</button>
<div style={{fontSize:10, color:"var(--text-3)"}}>
<label style={{display:"block", marginBottom:3, textTransform:"uppercase", letterSpacing:"0.06em"}}>
Serveur API
{/* Theme toggle */}
<div className="theme-toggle">
<span>{theme === "dark" ? "🌙" : "☀️"}</span>
<label className="theme-switch">
<input type="checkbox" checked={theme === "light"}
onChange={e => setTheme(e.target.checked ? "light" : "dark")} />
<span className="theme-switch-track" />
<span className="theme-switch-thumb" />
</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"
/>
<span>{theme === "light" ? "Clair" : "Sombre"}</span>
</div>
</div>
</aside>
@@ -163,11 +185,10 @@ export default function App() {
{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} />}
{active === "profile" && <UserProfile apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} theme={theme} setTheme={setTheme} />}
</div>
</main>
</div>
+140
View File
@@ -0,0 +1,140 @@
import { useState } from "react";
/**
* AccountModal Modale de création / modification d'un compte mail.
* Props:
* editAcc, accForm, setAccForm état du formulaire
* saving, modalErr
* showGeneratedPw, setShowGeneratedPw, generatePassword
* domains, mailDomains, accounts
* onSave, onClose
*/
export default function AccountModal({
editAcc, accForm, setAccForm,
saving, modalErr,
showGeneratedPw, setShowGeneratedPw, generatePassword,
domains, mailDomains, accounts,
onSave, onClose,
}) {
return (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{width:580, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">{editAcc ? "Modifier le compte" : "Nouveau compte"}</div>
<button className="modal-close" onClick={onClose}></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=>{
const d = e.target.value;
const dom = mailDomains.find(x=>x.name===d);
const q = (dom && dom.mb_quota_mb > 0) ? dom.mb_quota_mb : accForm.quota;
setAccForm({...accForm, domain:d, quota:q});
}}>
<option value=""> Choisir </option>
{domains.filter(d=>d!=="ALL").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}
</div>
</div>
)}
<div className="form-group">
<label className="form-label">Nom affiché</label>
<input className="form-input" placeholder="Prénom Nom"
value={accForm.name}
onChange={e=>setAccForm({...accForm,name:e.target.value})} />
</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>
<div style={{display:"flex",gap:6}}>
<input className="form-input"
type={showGeneratedPw ? "text" : "password"}
placeholder={editAcc ? "••••••••" : "Mot de passe"}
value={accForm.password}
onChange={e=>{setAccForm({...accForm,password:e.target.value});setShowGeneratedPw(false);}}
style={{flex:1,fontFamily:showGeneratedPw?"var(--font-mono)":"inherit"}}
/>
<button type="button" className="btn btn-secondary btn-sm"
title={showGeneratedPw ? "Masquer" : "Afficher"}
onClick={()=>setShowGeneratedPw(v=>!v)}
style={{flexShrink:0,fontSize:16}}>
{showGeneratedPw ? "🙈" : "👁"}
</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={generatePassword} title="Générer un mot de passe aléatoire"
style={{whiteSpace:"nowrap",flexShrink:0}}>
Générer
</button>
</div>
{showGeneratedPw && accForm.password && (
<div style={{fontSize:11,marginTop:4,color:"var(--yellow)"}}>
Notez ce mot de passe, il ne sera plus affiché.
</div>
)}
</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})} />
{(() => {
const dom = mailDomains.find(d=>d.name===(accForm.domain||editAcc?.domain));
if (!dom || !dom.max_quota_mb) return null;
const used = accounts
.filter(a=>a.domain===dom.name && a.id!==(editAcc?.id))
.reduce((s,a)=>s+(a.quota||0), 0);
const remaining = dom.max_quota_mb - used;
const ok = accForm.quota <= remaining;
return (
<div style={{fontSize:10,marginTop:3,color:ok?"var(--text-3)":"var(--red)"}}>
{ok
? `Quota global : ${dom.max_quota_mb} Mo — Alloué : ${used} Mo — Disponible : ${remaining} Mo`
: `⚠ Quota insuffisant : ${remaining} Mo disponibles sur ${dom.max_quota_mb} Mo`}
{dom.mb_quota_mb > 0 && ` — Max par BAL : ${dom.mb_quota_mb} Mo`}
</div>
);
})()}
</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={onClose}>Annuler</button>
<button className="btn btn-primary" onClick={onSave} disabled={saving}>
{saving ? "Sauvegarde…" : editAcc ? "Enregistrer" : "Créer"}
</button>
</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* AliasModal Modale de création / modification d'un alias mail.
*/
export default function AliasModal({
editAlias, aliasForm, setAliasForm,
saving, modalErr,
selDomain,
onSave, onClose,
}) {
return (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{width:580, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">{editAlias ? "Modifier l'alias" : "Nouvel alias"}</div>
<button className="modal-close" onClick={onClose}></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 (adresse complète)</label>
{editAlias ? (
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
{editAlias.source}
</div>
) : (
<input className="form-input" placeholder="contact@domain.fr"
value={aliasForm.source}
onChange={e=>{
const val = e.target.value;
const updates = {source: val};
if (val.includes("@")) {
const parts = val.split("@");
updates.domain = parts[parts.length-1];
}
setAliasForm({...aliasForm,...updates});
}}
autoFocus />
)}
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>
Email complet, ex: contact@infolix.fr
</div>
</div>
<div className="form-group">
<label className="form-label">Domaine</label>
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
{editAlias ? editAlias.domain : (aliasForm.domain || selDomain || "—")}
</div>
</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.source||editAlias?.source||"").includes("@") &&
(aliasForm.domain||editAlias?.domain)
? "@"+(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={onClose}>Annuler</button>
<button className="btn btn-primary" onClick={onSave} disabled={saving}>
{saving ? "Sauvegarde…" : editAlias ? "Enregistrer" : "Créer"}
</button>
</div>
</div>
</div>
);
}
+2 -11
View File
@@ -1,3 +1,4 @@
import useApi from "../hooks/useApi";
import { useState, useEffect, useCallback } from "react";
const SERVICE_COLORS = {
@@ -55,6 +56,7 @@ function StatusDot({ success }) {
}
export default function AuditLog({ apiBase, token, onUnauthorized }) {
const api = useApi(apiBase, token, onUnauthorized);
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
@@ -69,17 +71,6 @@ export default function AuditLog({ apiBase, token, onUnauthorized }) {
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);
+132 -30
View File
@@ -1,3 +1,5 @@
import useApi from "../hooks/useApi";
import { DnssecResultModal, DnssecManageModal } from "./DnssecModals";
import { useState, useEffect, useCallback } from "react";
import SyncToast from "./SyncToast";
@@ -13,6 +15,7 @@ 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 api = useApi(apiBase, token, onUnauthorized);
const [zones, setZones] = useState([]);
const [activeZone, setActiveZone] = useState(null);
const [records, setRecords] = useState([]);
@@ -45,26 +48,48 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
const [confirmToggle, setConfirmToggle] = useState(null); // record pending toggle
const [confirmInput, setConfirmInput] = useState("");
const [pendingCount, setPendingCount] = useState(0);
const [dnsConfig, setDnsConfig] = useState({ default_ns: [], dnssec_enabled: false });
const [dnssecResult, setDnssecResult] = useState(null);
const [dnssecModal, setDnssecModal] = useState(false); // zone DNSSEC panel
const [dnssecLoading, setDnssecLoading] = useState(false);
const [dnssecZoneData, setDnssecZoneData] = useState(null); // {ds_records, action, ...}
const [dnssecCopied, setDnssecCopied] = useState(null);
const [dnssecConfirm, setDnssecConfirm] = useState(null); // "enable" | "disable"
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.");
const openDnssec = async () => {
setDnssecModal(true);
setDnssecZoneData(null);
setDnssecLoading(true);
try {
// Just get current DS records without enabling
const r = await api(`/dns/zones/${activeZone}/dnssec?action=get_ds`, { method: "POST" });
setDnssecZoneData(r);
} catch(e) {
setDnssecZoneData({ error: e.message });
} finally {
setDnssecLoading(false);
}
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(err.detail || r.statusText);
};
const doDnssecAction = async (action, policy = "default") => {
setDnssecLoading(true);
try {
const r = await api(
`/dns/zones/${activeZone}/dnssec?action=${action}&dnssec_policy=${policy}`,
{ method: "POST" }
);
setDnssecZoneData(r);
} catch(e) {
setDnssecZoneData({ error: e.message, action });
} finally {
setDnssecLoading(false);
}
// 204 No Content no body to parse
if (r.status === 204) return null;
return r.json();
}, [apiBase, token]);
};
useEffect(() => {
api("/dns/config").then(c => setDnsConfig(c)).catch(() => {});
setLoading(true);
api("/dns/zones")
.then((data) => {
@@ -144,8 +169,40 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
}
};
const validateRecord = (type, value) => {
const fqdn = /^[a-zA-Z0-9._-]+\.$/; // must end with .
const hostname = /^[a-zA-Z0-9._-]+$/;
if (["MX", "NS", "CNAME", "PTR"].includes(type)) {
const v = value.trim();
if (!v) return "La valeur ne peut pas être vide";
// Must end with a dot (FQDN) or be @ or relative
if (!v.endsWith(".") && v !== "@" && !v.includes(" ")) {
return `Les enregistrements ${type} doivent se terminer par un point (ex: mail.example.com.)`;
}
}
if (type === "MX") {
// MX value is just the hostname (priority is separate)
const v = value.trim();
if (!v) return "Le serveur MX ne peut pas être vide";
}
if (type === "A") {
const v = value.trim();
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(v))
return "Adresse IPv4 invalide";
}
if (type === "AAAA") {
const v = value.trim();
if (!/^[0-9a-fA-F:]+$/.test(v))
return "Adresse IPv6 invalide";
}
return null;
};
const saveRecord = async () => {
setSaving(true);
// Validate value
const valErr = validateRecord(form.type, form.value);
if (valErr) { setModalError(valErr); setSaving(false); return; }
// Build composite value for DS and TLSA from structured sub-fields
let compositeValue = form.value;
if (form.type === "DS")
@@ -308,11 +365,16 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
try {
const r = await api("/dns/sync", { method: "POST" });
setSyncSuccess(true);
setSyncMsg(r.message);
setSyncMsg(r.message || "Synchronisation terminée.");
// Reload zones list
const updated = await api("/dns/zones");
setZones(updated);
if (updated.length > 0 && !updated.find(z => z.name === activeZone)) {
setActiveZone(updated[0].name);
const zone = updated.find(z => z.name === activeZone) ? activeZone : (updated[0]?.name || null);
setActiveZone(zone);
// Reload records for active zone
if (zone) {
const recs = await api(`/dns/zones/${zone}/records`);
setRecords(recs);
}
} catch(e) {
setSyncSuccess(false);
@@ -411,6 +473,10 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
</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={openDnssec}
style={{color:"var(--yellow)"}} title="Gérer DNSSEC pour cette zone">
🔒 DNSSEC
</button>
<button className="btn btn-ghost btn-sm" onClick={reloadZone} disabled={reloading}
title="Forcer rndc reload sur cette zone">
{reloading
@@ -499,8 +565,8 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
>
{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>
<button className="btn-icon" onClick={() => openEdit(rec)} title="Modifier"></button>
<button className="btn-icon" style={{color:"var(--red)"}} onClick={() => deleteRecord(rec)} title="Supprimer"></button>
</div>
</td>
</tr>
@@ -516,7 +582,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
{/* Record Modal */}
{showModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="modal">
<div className="modal" style={{width:680, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">{editRecord ? "Modifier l'enregistrement" : "Nouvel enregistrement"}</div>
<button className="modal-close" onClick={() => setShowModal(false)}></button>
@@ -550,23 +616,39 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
)}
</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 !== "DS" && form.type !== "TLSA" && (() => {
const multiline = ["TXT", "CAA"].includes(form.type);
const 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 === "TXT" ? "v=spf1 include:_spf.example.com ~all\n\nou clé DKIM : p=MIIB..." :
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>
)}
"value";
return (
<div className="form-group">
<label className="form-label">Value</label>
{multiline ? (
<textarea className="form-textarea"
placeholder={placeholder}
rows={form.type === "TXT" ? 4 : 2}
value={form.value}
onChange={(e) => setForm({...form, value: e.target.value})}
style={{fontFamily:"var(--font-mono)", fontSize:12, resize:"vertical"}}
/>
) : (
<input className="form-input"
placeholder={placeholder}
value={form.value}
onChange={(e) => setForm({...form, value: e.target.value})} />
)}
</div>
);
})()}
{/* ── DS structured fields ── */}
{form.type === "DS" && (
@@ -983,6 +1065,26 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
</div>
)}
{/* ── DNSSEC result modal ─────────────────────────────────────────── */}
<DnssecResultModal
dnssecResult={dnssecResult}
setDnssecResult={setDnssecResult}
/>
{/* ── DNSSEC management modal ──────────────────────────────────────── */}
<DnssecManageModal
dnssecModal={dnssecModal}
setDnssecModal={setDnssecModal}
activeZone={activeZone}
dnssecLoading={dnssecLoading}
dnssecZoneData={dnssecZoneData}
dnssecConfirm={dnssecConfirm}
setDnssecConfirm={setDnssecConfirm}
dnssecCopied={dnssecCopied}
setDnssecCopied={setDnssecCopied}
doDnssecAction={doDnssecAction}
/>
</>
);
}
+252
View File
@@ -0,0 +1,252 @@
/**
* DnssecModals Modales DNSSEC extraites de DNSManager.
* DnssecResultModal : résultat d'une action DNSSEC
* DnssecManageModal : panneau de gestion complet (DS records + actions)
*/
export function DnssecResultModal({ dnssecResult, setDnssecResult }) {
if (!dnssecResult) return null;
return (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setDnssecResult(null)}>
<div className="modal" style={{width:540, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">
{dnssecResult.success ? "🔒 DNSSEC activé" : "⚠ DNSSEC — résultat"}
</div>
<button className="modal-close" onClick={()=>setDnssecResult(null)}></button>
</div>
<div className="modal-body">
<div className={`alert ${dnssecResult.success?"alert-success":"alert-error"}`}>
{dnssecResult.success ? "✓" : "⚠"} {dnssecResult.message || dnssecResult.error}
</div>
{dnssecResult.success && dnssecResult.method === "keygen_manual" && (
<div style={{fontSize:11,color:"var(--text-2)",lineHeight:1.7}}>
<div style={{fontWeight:600,color:"var(--text-1)",marginBottom:6}}>Étapes suivantes :</div>
<ol style={{paddingLeft:16,display:"flex",flexDirection:"column",gap:4}}>
<li>Vérifiez les clés dans <code style={{color:"var(--accent)"}}>{dnssecResult.key_dir || "voir config agent"}</code></li>
<li>Ajoutez <code style={{color:"var(--accent)"}}>dnssec-policy "default";</code> dans la section zone de <code>named.conf</code></li>
<li>Rechargez BIND : <code style={{color:"var(--accent)"}}>rndc reconfig</code></li>
<li>Publiez les enregistrements DS chez votre registrar</li>
</ol>
</div>
)}
{dnssecResult.success && dnssecResult.method === "rndc_sign" && (
<div style={{fontSize:11,color:"var(--text-2)"}}>
La zone est signée inline. Pensez à publier les enregistrements DS chez votre registrar pour activer la chaîne de confiance DNSSEC.
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setDnssecResult(null)}>Fermer</button>
</div>
</div>
</div>
);
}
export function DnssecManageModal({
dnssecModal, setDnssecModal,
activeZone,
dnssecLoading, dnssecZoneData,
dnssecConfirm, setDnssecConfirm,
dnssecCopied, setDnssecCopied,
doDnssecAction,
}) {
if (!dnssecModal) return null;
const algoNames = {"1":"RSA/MD5","3":"DSA","5":"RSA/SHA-1","6":"DSA/SHA-1",
"7":"RSA/SHA-1-NSEC3","8":"RSA/SHA-256","10":"RSA/SHA-512",
"12":"GOST","13":"ECDSA/P-256/SHA-256","14":"ECDSA/P-384/SHA-384",
"15":"Ed25519","16":"Ed448"};
const digestNames = {"1":"SHA-1 (déconseillé)","2":"SHA-256 (recommandé)","4":"SHA-384"};
return (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setDnssecModal(false)}>
<div className="modal" style={{width:640, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">🔒 DNSSEC {activeZone}</div>
<button className="modal-close" onClick={()=>setDnssecModal(false)}></button>
</div>
<div className="modal-body">
{dnssecLoading && (
<div className="loading"><div className="spinner"/> Chargement</div>
)}
{!dnssecLoading && dnssecZoneData?.error && (
<div className="alert alert-error"> {dnssecZoneData.error}</div>
)}
{!dnssecLoading && dnssecZoneData && !dnssecZoneData.error && (<>
{dnssecZoneData.message && (
<div className="alert alert-success"> {dnssecZoneData.message}</div>
)}
{dnssecZoneData.ds_records?.length > 0 ? (
<div className="form-group">
<label className="form-label">
Enregistrements DS à publier chez votre registrar
</label>
{dnssecZoneData.ds_records.map((ds, i) => {
const parts = ds.trim().split(/\s+/);
const dsIdx = parts.findIndex(p => p === "DS");
const keyId = dsIdx >= 0 ? parts[dsIdx+1] : "?";
const algo = dsIdx >= 0 ? parts[dsIdx+2] : "?";
const digestType = dsIdx >= 0 ? parts[dsIdx+3] : "?";
const digest = dsIdx >= 0 ? parts.slice(dsIdx+4).join("") : "?";
return (
<div key={i} style={{
marginBottom:12, padding:"12px 14px", borderRadius:6,
background:"var(--bg-2)", border:"1px solid var(--border)"
}}>
<div style={{display:"grid", gridTemplateColumns:"130px 1fr", gap:"4px 12px",
fontSize:11, marginBottom:10}}>
<span style={{color:"var(--text-3)"}}>Key Tag (ID)</span>
<span style={{fontFamily:"var(--font-mono)",color:"var(--accent)",fontWeight:600}}>{keyId}</span>
<span style={{color:"var(--text-3)"}}>Algorithme</span>
<span style={{fontFamily:"var(--font-mono)"}}>{algo} <span style={{color:"var(--text-2)"}}>{algoNames[algo] || "inconnu"}</span></span>
<span style={{color:"var(--text-3)"}}>Type de condensat</span>
<span style={{fontFamily:"var(--font-mono)"}}>{digestType} <span style={{color:"var(--text-2)"}}>{digestNames[digestType] || "inconnu"}</span></span>
<span style={{color:"var(--text-3)"}}>Condensat (digest)</span>
<span style={{fontFamily:"var(--font-mono)",fontSize:10,wordBreak:"break-all",color:"var(--text-1)"}}>{digest}</span>
</div>
<div style={{display:"flex",gap:6,alignItems:"flex-start"}}>
<textarea className="form-textarea" readOnly rows={2}
style={{fontFamily:"var(--font-mono)",fontSize:10,flex:1,
resize:"none",background:"var(--bg-0)"}}
value={ds} />
<button className="btn btn-secondary btn-sm"
style={{flexShrink:0,marginTop:2}}
title="Copier l'enregistrement DS complet"
onClick={()=>{
navigator.clipboard.writeText(ds);
setDnssecCopied(i);
setTimeout(()=>setDnssecCopied(null),2000);
}}>
{dnssecCopied===i ? "✓ Copié" : "⎘ Copier"}
</button>
</div>
</div>
);
})}
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>
Clés dans : <code style={{color:"var(--accent)"}}>{dnssecZoneData.key_dir || "voir config agent"}</code>
</div>
</div>
) : (
<div style={{
padding:"20px", textAlign:"center",
color:"var(--text-2)", fontSize:12,
background:"var(--bg-2)", borderRadius:6,
border:"1px solid var(--border)"
}}>
<div style={{fontSize:24,marginBottom:8}}>🔓</div>
<div style={{fontWeight:600,marginBottom:4}}>DNSSEC non configuré</div>
<div style={{color:"var(--text-3)"}}>
Activez DNSSEC pour générer les clés et obtenir les enregistrements DS.
</div>
</div>
)}
</>)}
{!dnssecLoading && (
<div style={{
marginTop:8, padding:"12px 14px",
background:"var(--bg-2)", borderRadius:6,
border:"1px solid var(--border)"
}}>
<div style={{fontSize:11,color:"var(--text-2)",marginBottom:10,fontWeight:600}}>
Actions DNSSEC
</div>
{dnssecConfirm === "enable" && (
<div style={{
padding:"12px 14px", borderRadius:6, marginBottom:10,
background:"rgba(255,183,0,0.08)",
border:"1px solid var(--yellow)", color:"var(--yellow)",
}}>
<div style={{fontWeight:700, marginBottom:6}}>
Attention Risque d'interruption de service
</div>
<div style={{fontSize:11, lineHeight:1.7, color:"var(--text-1)"}}>
L'activation du DNSSEC <strong>peut rendre la zone inaccessible</strong> si&nbsp;:
<ul style={{paddingLeft:16, marginTop:4}}>
<li>Les enregistrements DS ne sont pas publiés chez le registrar</li>
<li>La chaîne de confiance est incomplète</li>
<li>named.conf.local n'est pas correctement configuré</li>
</ul>
<strong>Vérifiez la configuration avant de continuer.</strong>
</div>
<div style={{display:"flex", gap:8, marginTop:10}}>
<button className="btn btn-primary btn-sm"
onClick={()=>{setDnssecConfirm(null);doDnssecAction("enable");}}>
Confirmer l'activation
</button>
<button className="btn btn-secondary btn-sm"
onClick={()=>setDnssecConfirm(null)}>
Annuler
</button>
</div>
</div>
)}
{dnssecConfirm === "disable" && (
<div style={{
padding:"12px 14px", borderRadius:6, marginBottom:10,
background:"var(--red-dim)",
border:"1px solid var(--red)", color:"var(--red)",
}}>
<div style={{fontWeight:700, marginBottom:6}}>
Attention Désactivation DNSSEC
</div>
<div style={{fontSize:11, lineHeight:1.7, color:"var(--text-1)"}}>
La désactivation du DNSSEC <strong>peut rendre la zone inaccessible</strong>
si les enregistrements DS sont toujours publiés chez le registrar.
<ul style={{paddingLeft:16, marginTop:4}}>
<li>Supprimez d'abord les DS chez le registrar</li>
<li>Attendez que le TTL expire (propagation DNS)</li>
<li>Puis désactivez ici</li>
</ul>
</div>
<div style={{display:"flex", gap:8, marginTop:10}}>
<button className="btn btn-danger btn-sm"
onClick={()=>{setDnssecConfirm(null);doDnssecAction("disable");}}>
Confirmer la désactivation
</button>
<button className="btn btn-secondary btn-sm"
onClick={()=>setDnssecConfirm(null)}>
Annuler
</button>
</div>
</div>
)}
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<button className="btn btn-primary btn-sm"
onClick={()=>setDnssecConfirm("enable")}
disabled={dnssecLoading || dnssecConfirm !== null}>
🔒 Activer DNSSEC
</button>
<button className="btn btn-secondary btn-sm"
onClick={()=>doDnssecAction("get_ds")}
disabled={dnssecLoading || dnssecConfirm !== null}>
Récupérer les DS
</button>
<button className="btn btn-ghost btn-sm"
style={{color:"var(--red)", marginLeft:"auto"}}
onClick={()=>setDnssecConfirm("disable")}
disabled={dnssecLoading || dnssecConfirm !== null}>
🔓 Désactiver
</button>
</div>
<div style={{fontSize:10,color:"var(--text-3)",marginTop:8,lineHeight:1.7}}>
Ajoute <code>dnssec-policy "default"</code> dans le bloc zone de
<code> named.conf.local</code> et exécute <code>rndc reconfig</code>.
BIND 9.20+ active automatiquement l'inline-signing les clés sont générées
dans <code>key-directory</code> (configuré dans l'agent DNS).
Publiez ensuite les enregistrements DS chez votre registrar pour établir
la chaîne de confiance DNSSEC.
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setDnssecModal(false)}>Fermer</button>
</div>
</div>
</div>
);
}
+234 -86
View File
@@ -1,10 +1,11 @@
import useApi from "../hooks/useApi";
import { useState, useEffect, useCallback } from "react";
const EMPTY_FORM = {
name: "", has_dns: false, has_mail: false,
name: "", has_dns: false, has_mail: false, create_default_aliases: true,
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,
max_accounts: 0, max_aliases: 0, max_quota_mb: 0, mb_quota_mb: 0,
};
function LimitInput({ label, value, onChange, hint }) {
@@ -50,7 +51,7 @@ function ServiceSection({ title, active, onToggle, color, children }) {
);
}
function DnsFields({ v, set }) {
function DnsFields({ v, set, defaultNs = [] }) {
return (
<>
<div className="form-row">
@@ -90,25 +91,39 @@ function DnsFields({ v, set }) {
<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>
</>
</div></>
);
}
function MailFields({ v, set }) {
function MailFields({ v, set, showDefaultAliases = false }) {
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})} />
<LimitInput label="Max comptes" value={v.max_accounts} onChange={x => set({...v,max_accounts:x})} hint="0 = illimité" />
<LimitInput label="Max alias" value={v.max_aliases} onChange={x => set({...v,max_aliases:x})} hint="0 = illimité" />
<LimitInput label="Quota global (Mo)" value={v.max_quota_mb} onChange={x => set({...v,max_quota_mb:x})} hint="0 = illimité" />
<LimitInput label="Quota par BAL (Mo)" value={v.mb_quota_mb} onChange={x => set({...v,mb_quota_mb:x})} hint="0 = illimité" />
</div>
<LimitInput label="Max listes de diffusion" value={v.max_lists} onChange={x => set({...v,max_lists:x})} />
{showDefaultAliases && (
<label style={{display:"flex",alignItems:"center",gap:8,marginTop:8,cursor:"pointer",fontSize:12}}>
<input type="checkbox" checked={v.create_default_aliases ?? true}
onChange={e=>set({...v,create_default_aliases:e.target.checked})}
style={{accentColor:"var(--accent)"}} />
<div>
<span style={{color:"var(--text-0)",fontWeight:500}}>Créer les alias par défaut</span>
<span style={{color:"var(--text-3)",marginLeft:6,fontSize:11}}>
abuse, hostmaster, postmaster, webmaster
</span>
</div>
</label>
)}
</>
);
}
export default function DomainsManager({ apiBase, user, token, onUnauthorized }) {
const api = useApi(apiBase, token, onUnauthorized);
const [domains, setDomains] = useState([]);
const [dnszones, setDnsZones] = useState([]);
const [loading, setLoading] = useState(false);
@@ -122,25 +137,17 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
const [editForm, setEditForm] = useState({});
const [confirmDel, setConfirmDel] = useState(null);
const [confirmInput, setConfirmInput] = useState("");
const [dkimResult, setDkimResult] = useState(null); // {domain, selector, txt_record, txt_name, dns}
const [dkimLoading, setDkimLoading] = useState(null); // domain being processed
const isAdmin = user?.roles?.some(r => ["global_admin","domain_admin"].includes(r.role));
const isAdmin = user?.roles?.some(r => ["global_admin","domain_admin"].includes(r.role));
const [dnsConfig, setDnsConfig] = useState({ default_ns: [], dnssec_enabled: false });
const [creating, setCreating] = useState(false); // full-screen loading overlay
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);
api("/dns/config").then(c => setDnsConfig(c)).catch(() => {});
Promise.all([api("/mail/domains-config"), api("/dns/zones")])
.then(([mdoms, dzones]) => { setDomains(mdoms); setDnsZones(dzones); })
.catch(e => setError(e.message))
@@ -150,29 +157,51 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
useEffect(() => { load(); }, [load]);
const createDomain = async () => {
const name = form.name.trim();
const name = form.name.trim().toLowerCase();
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);
// Validation AVANT toute création
if (form.has_mail) {
if (form.mb_quota_mb > 0 && form.max_quota_mb > 0 && form.mb_quota_mb > form.max_quota_mb) {
setModalError(
`Le quota par BAL (${form.mb_quota_mb} Mo) dépasse le quota global du domaine (${form.max_quota_mb} Mo). ` +
`Corrigez les quotas avant de créer le domaine.`
);
return;
}
}
//
setSaving(true); setCreating(true); setModalError(null);
const errors = [];
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}),
});
try {
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 }) });
} catch(e) { errors.push(`DNS : ${e.message}`); }
}
setShowCreate(false); setForm(EMPTY_FORM); load();
} catch(e) { setModalError(e.message); }
finally { setSaving(false); }
if (form.has_mail && errors.length === 0) {
// N'appelle le service mail QUE si le DNS a réussi (ou n'était pas requis)
try {
await api(`/mail/domains-config?name=${encodeURIComponent(name)}`, { method:"POST",
body:JSON.stringify({ has_mail:true, max_accounts:form.max_accounts,
max_aliases:form.max_aliases, max_quota_mb:form.max_quota_mb, mb_quota_mb:form.mb_quota_mb,
create_default_aliases: form.create_default_aliases ?? true }) });
} catch(e) { errors.push(`Mail : ${e.message}`); }
} else if (form.has_mail && errors.length > 0) {
errors.push("Mail : non créé car la création DNS a échoué.");
}
if (errors.length === 0) {
setShowCreate(false); setForm(EMPTY_FORM); load();
} else {
setModalError(errors.join("\n"));
load();
}
} finally { setSaving(false); setCreating(false); }
};
const openEdit = async (name) => {
@@ -182,9 +211,10 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
const ef = {
has_mail: !!md, has_dns: !!dz,
max_accounts: md?.max_accounts ?? 0,
max_aliases: md?.max_aliases ?? 0,
max_quota_mb: md?.max_quota_mb ?? 0,
max_lists: md?.max_lists ?? 0,
dns_ttl: 3600, dns_admin: "hostmaster",
mb_quota_mb: md?.mb_quota_mb ?? 0,
dns_ttl: 3600, dns_admin: dnsConfig.default_soa_admin||"hostmaster",
dns_refresh: 43200, dns_retry: 3600, dns_expire: 2419200, dns_negative_ttl: 3600,
};
try {
@@ -203,40 +233,57 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
};
const saveEdit = async () => {
setSaving(true); setModalError(null);
setSaving(true); setCreating(true); setModalError(null);
const name = editName;
const md = domains.find(d => d.name === name);
const dz = dnszones.find(z => z.name === name);
const errors = [];
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
// 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})});
try {
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 }) });
} catch(e) { errors.push(`DNS (création) : ${e.message}`); }
} 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})});
try {
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 }) });
} catch(e) { errors.push(`DNS (mise à jour) : ${e.message}`); }
} else if (!editForm.has_dns && dz) {
await api(`/dns/zones/${name}`, {method:"DELETE"});
try {
await api(`/dns/zones/${name}`, { method:"DELETE" });
} catch(e) { errors.push(`DNS (suppression) : ${e.message}`); }
}
setShowEdit(false); load();
} catch(e) { setModalError(e.message); }
finally { setSaving(false); }
// Mail
if (editForm.has_mail && !md) {
try {
await api(`/mail/domains-config?name=${encodeURIComponent(name)}`, { method:"POST",
body:JSON.stringify({ has_mail:true, max_accounts:editForm.max_accounts,
max_aliases:editForm.max_aliases, max_quota_mb:editForm.max_quota_mb, mb_quota_mb:editForm.mb_quota_mb }) });
} catch(e) { errors.push(`Mail (création) : ${e.message}`); }
} else if (editForm.has_mail && md) {
try {
await api(`/mail/domains-config/${encodeURIComponent(name)}`, { method:"PUT",
body:JSON.stringify({ has_mail:true, max_accounts:editForm.max_accounts,
max_aliases:editForm.max_aliases, max_quota_mb:editForm.max_quota_mb, mb_quota_mb:editForm.mb_quota_mb }) });
} catch(e) { errors.push(`Mail (mise à jour) : ${e.message}`); }
} else if (!editForm.has_mail && md) {
try {
await api(`/mail/domains/${encodeURIComponent(name)}`, { method:"DELETE" });
} catch(e) { errors.push(`Mail (suppression) : ${e.message}`); }
}
if (errors.length === 0) {
setShowEdit(false);
} else {
setModalError(errors.join("\n"));
}
load();
} finally { setSaving(false); setCreating(false); }
};
const deleteDomain = async (name) => {
@@ -246,14 +293,30 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
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"});
// Always attempt mail domain deletion catch 404 silently
try { await api(`/mail/domains/${encodeURIComponent(name)}`, {method:"DELETE"}); }
catch(e) { if (!e.message?.includes("404") && !e.message?.includes("introuvable")) throw e; }
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();
const generateDkim = async (domainName, selector = "mail") => {
setDkimLoading(domainName);
setDkimResult(null);
try {
const r = await api(`/mail/domains/${domainName}/dkim?selector=${selector}`, { method: "POST" });
setDkimResult(r);
} catch(e) {
setDkimResult({ error: e.message, domain: domainName });
} finally {
setDkimLoading(null);
load();
}
};
const allNames = [...new Set([...domains.map(d=>d.name),...dnszones.map(z=>z.name)])].filter(n=>n!=="ALL").sort();
return (
<>
@@ -263,7 +326,7 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
</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>
<button className="btn btn-primary" onClick={()=>{setForm({...EMPTY_FORM, dns_admin: dnsConfig.default_soa_admin||'hostmaster'});setModalError(null);setShowCreate(true);}}>+ Nouveau domaine</button>
</div>}
<div className="card">
@@ -276,8 +339,7 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
<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>}
<th>Comptes mail</th><th>Quota global</th>{isAdmin && <th></th>}
</tr></thead>
<tbody>
{allNames.map(name => {
@@ -293,11 +355,18 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
</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>
<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>{isAdmin && <td><div className="cell-actions">
<button className="btn-icon" onClick={()=>openEdit(name)} title="Configurer"></button>
{md && (
<button className="btn btn-ghost btn-sm"
style={{color:"var(--yellow)"}}
onClick={()=>generateDkim(name)}
disabled={dkimLoading === name}
title="Générer / renouveler la clé DKIM">
{dkimLoading === name ? "…" : "🔑"}
</button>
)}
<button className="btn-icon" style={{color:"var(--red)"}} onClick={()=>deleteDomain(name)} title="Supprimer"></button>
</div></td>}
</tr>
);
@@ -312,13 +381,17 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
{/* Create modal */}
{showCreate && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowCreate(false)}>
<div className="modal" style={{width:540}}>
<div className="modal" style={{width:720, maxWidth:"96vw"}}>
<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>}
{modalError && (
<div className="alert alert-error" style={{marginBottom:0,whiteSpace:"pre-line"}}>
{modalError}
</div>
)}
<div className="form-group">
<label className="form-label">Nom du domaine</label>
<input className="form-input" placeholder="example.com" value={form.name}
@@ -327,11 +400,11 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
<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}/>
<DnsFields v={form} set={setForm} defaultNs={dnsConfig.default_ns}/>
</ServiceSection>
<ServiceSection title="◉ Mail" active={form.has_mail} color="var(--yellow)"
onToggle={()=>setForm({...form,has_mail:!form.has_mail})}>
<MailFields v={form} set={setForm}/>
<MailFields v={form} set={setForm} showDefaultAliases={true}/>
</ServiceSection>
</div>
<div className="modal-footer">
@@ -345,17 +418,21 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
{/* 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" style={{width:720, maxWidth:"96vw"}}>
<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>}
{modalError && (
<div className="alert alert-error" style={{marginBottom:0,whiteSpace:"pre-line"}}>
{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}/>
<DnsFields v={editForm} set={setEditForm} defaultNs={dnsConfig.default_ns}/>
</ServiceSection>
<ServiceSection title="◉ Mail" active={editForm.has_mail} color="var(--yellow)"
onToggle={()=>setEditForm({...editForm,has_mail:!editForm.has_mail})}>
@@ -406,6 +483,77 @@ export default function DomainsManager({ apiBase, user, token, onUnauthorized })
</div>
</div>
)}
{/* ── Full-screen loading overlay ─────────────────────────────────── */}
{creating && (
<div style={{
position:"fixed", inset:0, zIndex:200,
background:"rgba(0,0,0,0.6)", backdropFilter:"blur(3px)",
display:"flex", alignItems:"center", justifyContent:"center",
flexDirection:"column", gap:16,
}}>
<div className="spinner" style={{width:32,height:32,borderWidth:4}}/>
<div style={{color:"var(--text-0)",fontSize:14,fontFamily:"var(--font-display)",
fontWeight:600}}>
Chargement en cours
</div>
</div>
)}
{/* ── DKIM result modal ─────────────────────────────────────────────── */}
{dkimResult && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setDkimResult(null)}>
<div className="modal" style={{width:640, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">
{dkimResult.error ? "⚠ Erreur DKIM" : "🔑 Clé DKIM générée"}
</div>
<button className="modal-close" onClick={()=>setDkimResult(null)}></button>
</div>
<div className="modal-body">
{dkimResult.error ? (
<div className="alert alert-error"> {dkimResult.error}</div>
) : (<>
<div className="form-group">
<label className="form-label">Domaine</label>
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
{dkimResult.domain}
</div>
</div>
<div className="form-group">
<label className="form-label">Sélecteur</label>
<div className="form-input" style={{background:"var(--bg-0)",fontFamily:"var(--font-mono)"}}>
{dkimResult.selector}
</div>
</div>
<div className="form-group">
<label className="form-label">Nom de l'enregistrement DNS</label>
<div className="form-input" style={{background:"var(--bg-0)",fontFamily:"var(--font-mono)",color:"var(--accent)"}}>
{dkimResult.txt_name}.
</div>
</div>
<div className="form-group">
<label className="form-label">Valeur TXT</label>
<textarea className="form-textarea" readOnly rows={4}
style={{fontFamily:"var(--font-mono)",fontSize:11,resize:"vertical"}}
value={dkimResult.txt_record} />
</div>
{dkimResult.dns ? (
<div className="alert alert-success">
Enregistrement DNS {dkimResult.dns.applied ? "appliqué" : "mis en file d'attente"} dans la zone {dkimResult.dns.zone}
</div>
) : (
<div className="alert" style={{background:"var(--yellow-dim)",borderColor:"var(--yellow)",color:"var(--yellow)"}}>
Zone DNS "{dkimResult.domain}" non trouvée dans NetAdmin ajoutez manuellement l'enregistrement ci-dessus.
</div>
)}
</>)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setDkimResult(null)}>Fermer</button>
</div>
</div>
</div>
)}
</>
);
}
+157 -225
View File
@@ -22,14 +22,40 @@ function buildAuthUrl(cfg, challenge, state) {
return `${cfg.authorization_endpoint}?${params}`;
}
// TOTP verify step (TOTP already configured)
// Shared UI helpers
function Spinner() {
return <span style={{display:"inline-block",width:12,height:12,border:"2px solid var(--border)",
borderTopColor:"var(--accent)",borderRadius:"50%",animation:"spin 0.8s linear infinite",
marginRight:6}} />;
}
function Alert({ msg }) {
return (
<div style={{background:"var(--red-dim)",border:"1px solid var(--red)",borderRadius:6,
padding:"8px 12px",marginBottom:14,color:"var(--red)",fontSize:12}}>
{msg}
</div>
);
}
const OVERLAY_STYLE = {
position:"fixed", inset:0, display:"flex", alignItems:"center", justifyContent:"center",
background:"var(--bg-0)",
};
const CARD_STYLE = {
width:360, background:"var(--bg-1)", border:"1px solid var(--border-hi)",
borderRadius:12, padding:32, boxShadow:"var(--shadow-modal)",
};
const BRAND_STYLE = { textAlign:"center", marginBottom:28 };
const FULL_BTN = { width:"100%", justifyContent:"center" };
// TOTP verify step
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;
if (!code.trim()) { setError("Code requis"); return; }
setLoading(true); setError(null);
try {
const r = await fetch(`${apiBase}/auth/totp/validate-login`, {
@@ -39,58 +65,50 @@ function TotpVerifyStep({ apiBase, pendingToken, onSuccess, onBack }) {
});
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); }
onSuccess(data);
} catch(e) {
setError(e.message);
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}}>
<div style={{fontSize:36}}>🔐</div>
<div style={{fontFamily:"var(--font-display)",fontSize:18,fontWeight:700,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 style={{fontSize:11,color:"var(--text-2)",marginTop:4}}>
Saisissez le code de 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"}
<div className="form-group" style={{marginBottom:20}}>
<label className="form-label">Code à 6 chiffres</label>
<input className="form-input" placeholder="000000" maxLength={6}
value={code} onChange={e => setCode(e.target.value.replace(/\D/g,""))}
onKeyDown={e => e.key === "Enter" && submit()}
style={{textAlign:"center",letterSpacing:"0.3em",fontSize:20}}
autoFocus />
</div>
<button className="btn btn-primary" onClick={submit} disabled={loading} style={FULL_BTN}>
{loading ? <><Spinner />Vérification</> : "Valider"}
</button>
<button className="btn btn-ghost btn-sm" style={{marginTop:10, width:"100%"}} onClick={onBack}>
Retour à la connexion
<button className="btn btn-ghost btn-sm" onClick={onBack}
style={{width:"100%",justifyContent:"center",marginTop:10,color:"var(--text-2)"}}>
Retour
</button>
</div>
</div>
);
}
// TOTP setup step (TOTP required but not yet configured)
// TOTP setup step
function TotpSetupStep({ apiBase, pendingToken, onSuccess, onBack }) {
const [step, setStep] = useState("qr"); // qr | verify
const [qrData, setQrData] = useState(null);
const [qr, setQr] = useState(null);
const [secret, setSecret] = useState(null);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -101,220 +119,140 @@ function TotpSetupStep({ apiBase, pendingToken, onSuccess, onBack }) {
headers: { "Authorization": `Bearer ${pendingToken}` },
})
.then(r => r.json())
.then(d => setQrData(d))
.catch(e => setError(e.message));
}, []); // eslint-disable-line
.then(d => { setQr(d.qr_code); setSecret(d.secret); })
.catch(e => setError("Impossible de générer le QR code : " + e.message));
}, [apiBase, pendingToken]);
const verify = async () => {
if (code.length !== 6) return;
if (!code.trim()) { setError("Code requis"); return; }
setLoading(true); setError(null);
try {
// Step 1: activate TOTP with the code
const r1 = await fetch(`${apiBase}/auth/totp/verify`, {
const r = await fetch(`${apiBase}/auth/totp/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${pendingToken}` },
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 data = await r.json();
if (!r.ok) throw new Error(data.detail || "Code invalide");
// Upgrade session after TOTP setup
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); }
const data2 = await r2.json();
if (!r2.ok) throw new Error(data2.detail || "Erreur session");
onSuccess(data2);
} catch(e) {
setError(e.message);
setLoading(false);
}
};
return (
<div style={OVERLAY_STYLE}>
<div style={{...CARD_STYLE, maxWidth:420}}>
<div style={{...CARD_STYLE, width:400}}>
<div style={BRAND_STYLE}>
<div style={{fontSize:36}}>🔐</div>
<div style={{fontFamily:"var(--font-display)", fontSize:18, fontWeight:800, marginTop:8}}>
Configuration requise
<div style={{fontFamily:"var(--font-display)",fontSize:18,fontWeight:700,marginTop:8}}>
Configurer la 2FA
</div>
<div style={{fontSize:12, color:"var(--yellow)", marginTop:4}}>
Votre administrateur exige la double authentification
<div style={{fontSize:11,color:"var(--text-2)",marginTop:4}}>
Scannez le QR code avec votre application d'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}
{qr ? (
<div style={{textAlign:"center",marginBottom:18}}>
<img src={`data:image/png;base64,${qr}`} alt="QR Code 2FA"
style={{width:180,height:180,borderRadius:8,border:"1px solid var(--border)"}} />
{secret && (
<div style={{marginTop:10,fontSize:11,color:"var(--text-2)"}}>
Clé manuelle : <code style={{color:"var(--accent)",letterSpacing:"0.1em"}}>{secret}</code>
</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>
</>
)}
</div>
) : (
<div className="loading"><div className="spinner" />Chargement du QR code</div>
)}
<button className="btn btn-ghost btn-sm" style={{marginTop:10, width:"100%"}} onClick={onBack}>
Retour à la connexion
<div className="form-group" style={{marginBottom:20}}>
<label className="form-label">Code de vérification</label>
<input className="form-input" placeholder="000000" maxLength={6}
value={code} onChange={e => setCode(e.target.value.replace(/\D/g,""))}
onKeyDown={e => e.key === "Enter" && verify()}
style={{textAlign:"center",letterSpacing:"0.3em",fontSize:20}} />
</div>
<button className="btn btn-primary" onClick={verify} disabled={loading||!qr} style={FULL_BTN}>
{loading ? <><Spinner />Vérification</> : "Activer la 2FA"}
</button>
<button className="btn btn-ghost btn-sm" onClick={onBack}
style={{width:"100%",justifyContent:"center",marginTop:10,color:"var(--text-2)"}}>
Retour
</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 [showPw, setShowPw] = useState(false);
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);
const [totpState, setTotpState] = useState(null); // null | "verify" | "setup"
const [pendingToken,setPendingToken]= useState(null);
// 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
// Load OIDC config
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 });
});
fetch(`${apiBase}/auth/oidc/config`)
.then(r => r.json())
.then(d => setOidcCfg(d))
.catch(() => {});
}, [apiBase]);
// Handle Keycloak redirect-back
// Handle OIDC callback
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (!code) return;
if (!code || !state) return;
const savedState = sessionStorage.getItem("oidc_state");
const codeVerifier = sessionStorage.getItem("oidc_verifier");
const savedRedirect = sessionStorage.getItem("oidc_redirect_uri");
const verifier = sessionStorage.getItem("oidc_verifier");
const redirect_uri = sessionStorage.getItem("oidc_redirect_uri");
if (state !== savedState) return;
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);
setLoading(true);
fetch(`${apiBase}/auth/oidc/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, redirect_uri: savedRedirect, code_verifier: codeVerifier }),
body: JSON.stringify({ code, state, code_verifier: verifier, redirect_uri }),
})
.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
.then(r => r.json())
.then(data => { if (data.token) onLogin(data.token, data.user); })
.catch(e => setError("Erreur SSO : " + e.message))
.finally(() => setLoading(false));
}, [apiBase, onLogin]);
// Common handler for both local and OIDC login responses
const handleLoginResponse = useCallback((data) => {
if (data.totp_pending) {
const handleLoginResponse = (data) => {
if (data.totp_setup_required) {
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);
setTotpState("setup");
return;
}
if (data.totp_pending) {
setPendingToken(data.token);
setTotpState("verify");
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);
@@ -329,16 +267,16 @@ export default function LoginPage({ apiBase, onLogin }) {
handleLoginResponse(data);
} catch(e) {
setError(e.message);
} finally {
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 state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem("oidc_state", state);
sessionStorage.setItem("oidc_verifier", verifier);
@@ -350,62 +288,56 @@ export default function LoginPage({ apiBase, onLogin }) {
}
}, [oidcCfg]);
// TOTP steps (before login completes)
const onTotpSuccess = (data) => {
setTotpState(null);
setPendingToken(null);
onLogin(data.token, data.user);
};
const resetTotp = () => {
setTotpState(null);
setPendingToken(null);
};
// TOTP steps
if (totpState === "verify" && pendingToken) {
return (
<TotpVerifyStep
apiBase={apiBase}
pendingToken={pendingToken}
onSuccess={onTotpSuccess}
onBack={resetTotp}
/>
);
return <TotpVerifyStep apiBase={apiBase} pendingToken={pendingToken}
onSuccess={onTotpSuccess} onBack={resetTotp} />;
}
if (totpState === "setup" && pendingToken) {
return (
<TotpSetupStep
apiBase={apiBase}
pendingToken={pendingToken}
onSuccess={onTotpSuccess}
onBack={resetTotp}
/>
);
return <TotpSetupStep apiBase={apiBase} pendingToken={pendingToken}
onSuccess={onTotpSuccess} onBack={resetTotp} />;
}
// Normal login form
// 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}}>
<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"}
{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)"}} />
<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 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"
@@ -423,16 +355,16 @@ export default function LoginPage({ apiBase, onLogin }) {
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,
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 className="btn btn-primary" onClick={submit} disabled={loading} style={FULL_BTN}>
{loading ? <><Spinner />Connexion</> : "Se connecter"}
</button>
</div>
</div>
+144 -159
View File
@@ -1,7 +1,10 @@
import useApi from "../hooks/useApi";
import AccountModal from "./AccountModal";
import AliasModal from "./AliasModal";
import { useState, useEffect, useCallback } from "react";
import SyncToast from "./SyncToast";
const EMPTY_ACC = { username:"", domain:"", password:"", quota:1024, active:true };
const EMPTY_ACC = { username:"", name:"", domain:"", password:"", quota:1024, active:true };
const EMPTY_ALIAS = { source:"", destination:"", domain:"" };
function QuotaBar({ used, total }) {
@@ -65,7 +68,9 @@ function PendingBanner({ pending, onRetry, onDismiss, isGlobalAdmin }) {
}
export default function MailManager({ apiBase, token, user, onUnauthorized }) {
const api = useApi(apiBase, token, onUnauthorized);
const [accounts, setAccounts] = useState([]);
const [mailDomains, setMailDomains] = useState([]); // full domain config with quotas
const [domains, setDomains] = useState([]);
const [aliases, setAliases] = useState([]);
const [pending, setPending] = useState([]);
@@ -86,7 +91,8 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
const [showAccModal, setShowAccModal] = useState(false);
const [editAcc, setEditAcc] = useState(null);
const [accForm, setAccForm] = useState(EMPTY_ACC);
const [confirmDelAcc, setConfirmDelAcc] = useState(null);
const [confirmDelAcc, setConfirmDelAcc] = useState(null);
const [showGeneratedPw, setShowGeneratedPw] = useState(false);
// Aliases
const [aliasSearch, setAliasSearch] = useState("");
@@ -95,24 +101,15 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
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 userRoles = user?.roles || [];
const isGlobalAdmin = userRoles.some(r => r.role === "global_admin");
// Show admin controls if user has an explicit role, OR if roles is empty
// (means session was created before roles were loaded backend will enforce anyway)
const hasExplicitRole = userRoles.some(r =>
["mail_admin","domain_admin","mail_domain_admin"].includes(r.role)
);
const isAdmin = isGlobalAdmin || hasExplicitRole || (user && userRoles.length === 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."); }
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);
@@ -121,14 +118,17 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
api("/mail/domains"),
api("/mail/aliases"),
api("/mail/pending"),
api("/mail/domains-config"),
])
.then(([accs, doms, als, pend]) => {
.then(([accs, doms, als, pend, domCfgs]) => {
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));
setMailDomains(Array.isArray(domCfgs) ? domCfgs : []);
// Normalize: API may return [{name:"..."}, ...] or ["...", ...]
const domList = (doms || []).map(d => typeof d === "string" ? d : d.name).filter(Boolean);
setDomains(domList);
setSelDomain(prev => prev && domList.includes(prev) ? prev : (domList[0] || null));
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
@@ -184,12 +184,14 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
// Account CRUD
const openNewAcc = () => {
setEditAcc(null);
setAccForm({...EMPTY_ACC, domain: selDomain || ""});
const dom = mailDomains.find(d => d.name === selDomain);
const defaultQuota = (dom && dom.mb_quota_mb > 0) ? dom.mb_quota_mb : 1024;
setAccForm({...EMPTY_ACC, domain: selDomain || "", quota: defaultQuota});
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});
setAccForm({username:acc.username, name:acc.name||"", domain:acc.domain, password:"", quota:acc.quota, active:acc.active});
setModalErr(null); setShowGeneratedPw(false); setShowAccModal(true);
};
const saveAcc = async () => {
@@ -242,12 +244,18 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
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; }
// Valider chaque adresse goto avant d'envoyer
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const dests = aliasForm.destination.split(/[\n,]/).map(d=>d.trim()).filter(Boolean);
const invalid = dests.filter(d => !emailRe.test(d));
if (invalid.length > 0) {
setModalErr(`Adresse(s) invalide(s) : ${invalid.join(", ")}`);
return;
}
// Convert textarea (one per line) to CSV without spaces
const csvDest = aliasForm.destination
.split(/[\n,]/)
.map(d => d.trim())
.filter(Boolean)
.join(",");
const csvDest = dests.join(",");
setSaving(true); setModalErr(null);
try {
let aliasResult;
@@ -268,6 +276,15 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
} catch(e) { setModalErr(e.message); }
finally { setSaving(false); }
};
const toggleAlias = async (alias) => {
try {
// Toggle each row in the group
const group = getAliasGroup(alias.source, alias.domain);
await Promise.all(group.map(a => api(`/mail/aliases/${a.id}/toggle`, {method:"PATCH"})));
load();
} catch(e) { setError(e.message); }
};
const deleteAlias = async () => {
if (!confirmDelAlias) return;
setSaving(true);
@@ -287,7 +304,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
const filteredAccounts = selDomain
? accounts.filter(a =>
a.domain === selDomain &&
(!accSearch || `${a.username}@${a.domain}`.includes(accSearch.toLowerCase()))
(!accSearch || `${a.username}`.includes(accSearch.toLowerCase()))
)
: [];
@@ -296,7 +313,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
const groups = {};
for (const a of aliases) {
if (selDomain && a.domain !== selDomain) continue;
const key = `${a.source}@${a.domain}`;
const key = `${a.source}`;
if (!groups[key]) groups[key] = { ...a, destinations: [] };
groups[key].destinations.push(a.destination);
}
@@ -308,22 +325,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
);
})();
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 (
<>
@@ -337,28 +339,11 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<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"
className="btn btn-ghost btn-sm"
style={{
borderRadius:"4px 4px 0 0",
borderBottom:tab===t.id?"2px solid var(--accent)":"2px solid transparent",
@@ -380,7 +365,20 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
</div>
{/* Shared domain selector */}
<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.filter(d=>d!=="ALL").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>
{/* ── Accounts tab ─────────────────────────────────────────────────── */}
{tab === "accounts" && (
@@ -415,24 +413,23 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<div className="table-wrap">
<table>
<thead><tr>
<th>Adresse</th><th>Quota</th><th>Statut</th>
{isAdmin && <th></th>}
<th>Adresse</th><th>Nom</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}
{acc.username}
</td>
<td style={{color:"var(--text-1)"}}>{acc.name || "—"}</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>{isAdmin && (
<td><div className="cell-actions">
<button className="btn btn-ghost btn-sm" onClick={()=>openEditAcc(acc)}></button>
<button className="btn-icon" onClick={()=>openEditAcc(acc)}></button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}}
onClick={()=>setConfirmDelAcc(acc)}></button>
</div></td>
@@ -482,14 +479,13 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<div className="table-wrap">
<table>
<thead><tr>
<th>Source</th><th></th><th>Destination(s)</th>
{isAdmin && <th></th>}
<th>Source</th><th></th><th>Destination(s)</th><th>Statut</th>{isAdmin && <th></th>}
</tr></thead>
<tbody>
{aliasGroups.map(alias=>(
<tr key={`${alias.source}@${alias.domain}`}>
<tr key={`${alias.source}`}>
<td className="cell-mono" style={{color:"var(--accent)",fontWeight:500,whiteSpace:"nowrap"}}>
{alias.source}@{alias.domain}
{alias.source}
</td>
<td style={{color:"var(--text-3)",textAlign:"center",padding:"0 4px"}}></td>
<td>
@@ -503,9 +499,19 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
))}
</div>
</td>
{isAdmin && (
<td>
{alias.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={()=>openEditAlias(alias)}></button>
<button className="btn-icon" onClick={()=>openEditAlias(alias)} title="Modifier"></button>
<button className="btn btn-ghost btn-sm"
style={{color: alias.active ? "var(--green)" : "var(--red)"}}
onClick={()=>toggleAlias(alias)}
title={alias.active ? "Désactiver" : "Activer"}>
{alias.active ? "●" : "○"}
</button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}}
onClick={()=>setConfirmDelAlias(alias)}></button>
</div></td>
@@ -525,7 +531,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
{/* ── Account modal ─────────────────────────────────────────────────── */}
{showAccModal && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowAccModal(false)}>
<div className="modal" style={{maxWidth:460}}>
<div className="modal" style={{width:580, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">{editAcc?"Modifier le compte":"Nouveau compte"}</div>
<button className="modal-close" onClick={()=>setShowAccModal(false)}></button>
@@ -544,7 +550,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<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>)}
{domains.filter(d=>d!=="ALL").map(d=><option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
@@ -552,24 +558,69 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<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}
{editAcc.username}
</div>
</div>
)}
<div className="form-group">
<label className="form-label">Nom affiché</label>
<input className="form-input" placeholder="Prénom Nom"
value={accForm.name}
onChange={e=>setAccForm({...accForm,name:e.target.value})} />
</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 style={{display:"flex",gap:6}}>
<input className="form-input"
type={showGeneratedPw ? "text" : "password"}
placeholder={editAcc?"••••••••":"Mot de passe"}
value={accForm.password}
onChange={e=>{setAccForm({...accForm,password:e.target.value});setShowGeneratedPw(false);}}
style={{flex:1,fontFamily:showGeneratedPw?"var(--font-mono)":"inherit"}}
/>
<button type="button" className="btn btn-secondary btn-sm"
title={showGeneratedPw?"Masquer":"Afficher"}
onClick={()=>setShowGeneratedPw(v=>!v)}
style={{flexShrink:0,fontSize:16}}>
{showGeneratedPw ? "🙈" : "👁"}
</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={generatePassword} title="Générer un mot de passe aléatoire"
style={{whiteSpace:"nowrap",flexShrink:0}}>
Générer
</button>
</div>
{showGeneratedPw && accForm.password && (
<div style={{fontSize:11,marginTop:4,color:"var(--yellow)"}}>
Notez ce mot de passe, il ne sera plus affiché.
</div>
)}
</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})} />
{(() => {
const dom = mailDomains.find(d=>d.name===(accForm.domain||editAcc?.domain));
if (!dom || !dom.max_quota_mb) return null;
const used = accounts
.filter(a=>a.domain===dom.name && a.id!==(editAcc?.id))
.reduce((s,a)=>s+(a.quota||0), 0);
const remaining = dom.max_quota_mb - used;
const ok = accForm.quota <= remaining;
return (
<div style={{fontSize:10,marginTop:3,
color: ok ? "var(--text-3)" : "var(--red)"}}>
{ok
? `Quota global : ${dom.max_quota_mb} Mo — Alloué : ${used} Mo — Disponible : ${remaining} Mo`
: `⚠ Quota insuffisant : ${remaining} Mo disponibles sur ${dom.max_quota_mb} Mo`}
{dom.mb_quota_mb > 0 && ` — Max par BAL : ${dom.mb_quota_mb} Mo`}
</div>
);
})()}
</div>
<div className="form-group" style={{justifyContent:"flex-end",paddingTop:22}}>
<label style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:13}}>
@@ -592,78 +643,12 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
{/* ── 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>
<AliasModal
editAlias={editAlias} aliasForm={aliasForm} setAliasForm={setAliasForm}
saving={saving} modalErr={modalErr}
selDomain={selDomain}
onSave={saveAlias} onClose={()=>setShowAliasModal(false)}
/>
)}
{/* ── Delete account confirmation ───────────────────────────────────── */}
@@ -678,7 +663,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<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}
{confirmDelAcc.username}
</strong> ?
</p>
<div style={{marginTop:8,padding:"6px 10px",background:"var(--red-dim)",
@@ -706,7 +691,7 @@ export default function MailManager({ apiBase, token, user, onUnauthorized }) {
<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}
{confirmDelAlias.source}
</strong>
{confirmDelAlias.destinations?.length > 1
? ` (${confirmDelAlias.destinations.length} destinations)`
-298
View File
@@ -1,298 +0,0 @@
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>
)}
</>
);
}
+5 -15
View File
@@ -1,3 +1,4 @@
import useApi from "../hooks/useApi";
import { useState, useEffect, useCallback } from "react";
const SERVICE_COLORS = { dns:"var(--accent)", mail:"var(--yellow)", "":"var(--text-2)" };
@@ -64,20 +65,13 @@ function getTarget(payload) {
// Queue tab
function QueueTab({ apiBase, token, onUnauthorized }) {
const api = useApi(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);
@@ -191,6 +185,7 @@ function QueueTab({ apiBase, token, onUnauthorized }) {
// History tab
function HistoryTab({ apiBase, token, onUnauthorized }) {
const api = useApi(apiBase, token, onUnauthorized);
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
@@ -200,12 +195,6 @@ function HistoryTab({ apiBase, token, onUnauthorized }) {
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);
@@ -300,6 +289,7 @@ function HistoryTab({ apiBase, token, onUnauthorized }) {
// Main component
export default function SyncDashboard({ apiBase, token, onUnauthorized }) {
const api = useApi(apiBase, token, onUnauthorized);
const [tab, setTab] = useState("queue");
return <>
@@ -311,7 +301,7 @@ export default function SyncDashboard({ apiBase, token, onUnauthorized }) {
{/* 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"
<button key={t.id} onClick={()=>setTab(t.id)} className="btn btn-ghost btn-sm"
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)",
+52 -16
View File
@@ -1,6 +1,17 @@
import useApi from "../hooks/useApi";
import { useState, useEffect, useCallback } from "react";
export default function UserProfile({ apiBase, token, user, onUnauthorized }) {
export default function UserProfile({ apiBase, token, user, onUnauthorized, theme, setTheme }) {
const api = useApi(apiBase, token, onUnauthorized);
const savePreference = async (key, value) => {
try {
await fetch(`${apiBase}/auth/me/preferences`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ [key]: value }),
});
} catch {}
};
const [tab, setTab] = useState("password");
// Password change
@@ -18,18 +29,6 @@ export default function UserProfile({ apiBase, token, user, onUnauthorized }) {
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 () => {
@@ -109,10 +108,11 @@ export default function UserProfile({ apiBase, token, user, onUnauthorized }) {
{/* 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"},
{id:"password", label:"🔑 Mot de passe"},
{id:"totp", label:"🔐 Double authentification"},
{id:"appearance", label:"🎨 Apparence"},
].map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className="btn btn-ghost"
<button key={t.id} onClick={() => setTab(t.id)} className="btn btn-ghost btn-sm"
style={{
borderRadius:"4px 4px 0 0", marginBottom:-1, padding:"8px 18px",
borderBottom: tab===t.id ? "2px solid var(--accent)" : "2px solid transparent",
@@ -165,6 +165,42 @@ export default function UserProfile({ apiBase, token, user, onUnauthorized }) {
)}
{/* ── TOTP tab ─────────────────────────────────────────────────────── */}
{tab === "appearance" && (
<div className="card">
<div className="card-header">
<div className="card-title">🎨 Apparence</div>
</div>
<div className="card-body">
<div style={{marginBottom:8,fontSize:11,color:"var(--text-2)",
textTransform:"uppercase",letterSpacing:"0.1em"}}>Thème de l'interface</div>
<div style={{display:"flex",gap:12,marginBottom:16}}>
{[
{id:"dark", icon:"🌙", label:"Sombre", desc:"Fond noir — idéal en soirée"},
{id:"light", icon:"☀️", label:"Clair", desc:"Fond blanc — meilleure lisibilité de jour"},
].map(t => (
<div key={t.id} onClick={()=>{ if(setTheme){ setTheme(t.id); savePreference('theme', t.id); } }}
style={{
flex:1, padding:"16px 18px", borderRadius:8, cursor:"pointer",
border:`2px solid ${theme===t.id?"var(--accent)":"var(--border)"}`,
background:theme===t.id?"var(--accent-dim)":"var(--bg-2)",
transition:"all 0.15s",
}}>
<div style={{fontSize:22,marginBottom:6}}>{t.icon}</div>
<div style={{fontWeight:600,color:"var(--text-0)",marginBottom:3}}>{t.label}</div>
<div style={{fontSize:11,color:"var(--text-2)"}}>{t.desc}</div>
{theme===t.id&&<div style={{marginTop:8,fontSize:10,
color:"var(--accent)",textTransform:"uppercase",
letterSpacing:"0.08em"}}> Actif</div>}
</div>
))}
</div>
<div style={{fontSize:11,color:"var(--text-3)"}}>
Le thème est sauvegardé localement dans votre navigateur.
</div>
</div>
</div>
)}
{tab === "totp" && (
<div className="card" style={{maxWidth:500}}>
<div className="card-header">
+66 -19
View File
@@ -1,3 +1,4 @@
import useApi from "../hooks/useApi";
import { useState, useEffect, useCallback } from "react";
const ROLE_LABELS = {
@@ -25,7 +26,8 @@ function RoleBadge({ role, scope }) {
);
}
export default function UsersManager({ apiBase, token, currentUser }) {
export default function UsersManager({ apiBase, token, currentUser, onUnauthorized }) {
const api = useApi(apiBase, token, onUnauthorized);
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(false);
@@ -40,24 +42,9 @@ export default function UsersManager({ apiBase, token, currentUser }) {
const [saving, setSaving] = useState(false);
const [confirmUser, setConfirmUser] = useState(null); // user pending deletion
const [confirmRole, setConfirmRole] = useState(null); // {userId, roleId, role, scope}
const [confirmTotp, setConfirmTotp] = useState(null); // {type, userId, username, required?}
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(() => {
@@ -152,6 +139,29 @@ export default function UsersManager({ apiBase, token, currentUser }) {
catch(e) { setError(e.message); }
};
const handleTotpReset = (userId, username) => {
setConfirmTotp({ type:"reset", userId, username });
};
const handleTotpRequire = (userId, username, required) => {
setConfirmTotp({ type:"require", userId, username, required });
};
const doConfirmTotp = async () => {
if (!confirmTotp) return;
const { type, userId, username, required } = confirmTotp;
setConfirmTotp(null);
try {
if (type === "reset") {
await api(`/auth/users/${userId}/totp/reset`, { method:"POST" });
} else {
await api(`/auth/users/${userId}/totp/require`, { method:"POST",
body: JSON.stringify({ required }) });
}
load();
} catch(e) { setError(e.message); }
};
return (
<>
<div className="page-header">
@@ -262,7 +272,7 @@ export default function UsersManager({ apiBase, token, currentUser }) {
</td>
<td>
<div className="cell-actions">
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(u)} title="Modifier"></button>
<button className="btn-icon" 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>
@@ -278,7 +288,7 @@ export default function UsersManager({ apiBase, token, currentUser }) {
{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>
<button className="btn-icon" style={{color:"var(--red)"}} onClick={() => deleteUser(u)} title="Supprimer"></button>
)}
</div>
</td>
@@ -526,6 +536,43 @@ export default function UsersManager({ apiBase, token, currentUser }) {
</div>
)}
{/* ── TOTP confirm modal ─────────────────────────────────────────── */}
{confirmTotp && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setConfirmTotp(null)}>
<div className="modal" style={{width:420, maxWidth:"94vw"}}>
<div className="modal-header">
<div className="modal-title" style={{color: confirmTotp.type==="reset" ? "var(--yellow)" : confirmTotp.required ? "var(--accent)" : "var(--red)"}}>
{confirmTotp.type === "reset" ? "🔐 Réinitialiser le TOTP"
: confirmTotp.required ? "🔐 Forcer la 2FA"
: "🔓 Retirer l'obligation 2FA"}
</div>
<button className="modal-close" onClick={()=>setConfirmTotp(null)}></button>
</div>
<div className="modal-body">
{confirmTotp.type === "reset" ? (
<p style={{color:"var(--text-1)",lineHeight:1.7}}>
La clé 2FA de <strong>{confirmTotp.username}</strong> sera supprimée.
L'utilisateur devra re-scanner un QR code à sa prochaine connexion.
</p>
) : confirmTotp.required ? (
<p style={{color:"var(--text-1)",lineHeight:1.7}}>
La 2FA sera <strong>obligatoire</strong> pour <strong>{confirmTotp.username}</strong>.
Si non configurée, l'utilisateur sera contraint de la configurer à sa prochaine connexion.
</p>
) : (
<p style={{color:"var(--text-1)",lineHeight:1.7}}>
L'obligation 2FA sera <strong>retirée</strong> pour <strong>{confirmTotp.username}</strong>.
La 2FA restera active si déjà configurée, mais ne sera plus forcée.
</p>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setConfirmTotp(null)}>Annuler</button>
<button className="btn btn-danger" onClick={doConfirmTotp}>Confirmer</button>
</div>
</div>
</div>
)}
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback } from "react";
/**
* Hook useApi client HTTP centralisé pour NetAdmin.
*
* Usage:
* const api = useApi(apiBase, token, onUnauthorized);
* const data = await api("/dns/zones");
* await api("/dns/zones/example.com", { method: "DELETE" });
*/
export default function useApi(apiBase, token, onUnauthorized) {
return 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 }));
const detail = err.detail;
// Pydantic 422 retourne detail comme tableau [{loc, msg, type}]
if (Array.isArray(detail)) {
throw new Error(detail.map(d => d.msg || JSON.stringify(d)).join(" ; "));
}
throw new Error(typeof detail === "string" ? detail : r.statusText);
}
// 204 No Content — pas de body
if (r.status === 204) return null;
return r.json();
}, [apiBase, token, onUnauthorized]);
}
+7
View File
@@ -1,3 +1,10 @@
// Fonts (served locally no Google Fonts dependency)
import '@fontsource/jetbrains-mono/300.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/600.css'
import '@fontsource-variable/syne'
//
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""
migrate_postfixadmin.py Migration PostfixAdmin netadmin_mail
================================================================
Lit depuis la base PostfixAdmin existante (base: postfix)
Crée et peuple la nouvelle base netadmin_mail
Usage:
python3 migrate_postfixadmin.py --dry-run # aperçu sans écriture
python3 migrate_postfixadmin.py # migration réelle
Configuration via variables d'environnement ou arguments :
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD
SOURCE_DB (défaut: postfix)
TARGET_DB (défaut: netadmin_mail)
"""
import argparse
import logging
import sys
from datetime import datetime
try:
import pymysql
import pymysql.cursors
except ImportError:
print("ERROR: pip install PyMySQL")
sys.exit(1)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [migrate] %(levelname)s %(message)s"
)
log = logging.getLogger("migrate")
# ── Config ────────────────────────────────────────────────────────────────
def get_args():
p = argparse.ArgumentParser(description="Migrate PostfixAdmin → netadmin_mail")
p.add_argument("--host", default="localhost")
p.add_argument("--port", default=3306, type=int)
p.add_argument("--user", default="root")
p.add_argument("--password", default="")
p.add_argument("--source-db", default="postfix")
p.add_argument("--target-db", default="netadmin_mail")
p.add_argument("--dry-run", action="store_true",
help="Affiche ce qui sera fait sans écrire en base")
return p.parse_args()
def connect(args, database=None):
return pymysql.connect(
host=args.host, port=args.port,
user=args.user, password=args.password,
database=database,
cursorclass=pymysql.cursors.DictCursor,
charset="utf8mb4",
)
# ── Schéma cible ──────────────────────────────────────────────────────────
SCHEMA = """
CREATE TABLE IF NOT EXISTS domain (
domain VARCHAR(255) NOT NULL PRIMARY KEY,
description VARCHAR(255) DEFAULT '',
max_accounts INT NOT NULL DEFAULT 0,
max_aliases INT NOT NULL DEFAULT 0,
max_quota_mb INT NOT NULL DEFAULT 0,
mb_quota_mb INT NOT NULL DEFAULT 0,
active TINYINT(1) NOT NULL DEFAULT 1,
created DATETIME NOT NULL DEFAULT NOW(),
modified DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mailbox (
username VARCHAR(255) NOT NULL PRIMARY KEY,
password VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
domain VARCHAR(255) NOT NULL,
quota_mb INT NOT NULL DEFAULT 1024,
active TINYINT(1) NOT NULL DEFAULT 1,
created DATETIME NOT NULL DEFAULT NOW(),
modified DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS alias (
address VARCHAR(255) NOT NULL PRIMARY KEY,
goto TEXT NOT NULL,
domain VARCHAR(255) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created DATETIME NOT NULL DEFAULT NOW(),
modified DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sender_login_maps (
sender VARCHAR(255) NOT NULL PRIMARY KEY,
authorized TEXT NOT NULL,
domain VARCHAR(255) NOT NULL,
created DATETIME NOT NULL DEFAULT NOW(),
modified DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
def create_schema(conn):
with conn.cursor() as cur:
for stmt in SCHEMA.strip().split(";"):
stmt = stmt.strip()
if stmt:
cur.execute(stmt)
conn.commit()
log.info("Schéma netadmin_mail créé ✓")
# ── Helpers mot de passe ──────────────────────────────────────────────────
def normalize_password(raw: str) -> str | None:
"""
Normalise le mot de passe vers le format {SHA512-CRYPT}.
- Déjà {SHA512-CRYPT}xxx conservé tel quel
- Commence par $6$ préfixe {SHA512-CRYPT} ajouté
- $1$ (MD5) ou $2 (bcrypt) non migrables None
- Vide None
"""
if not raw:
return None
if raw.startswith("{SHA512-CRYPT}"):
return raw
if raw.startswith("$6$"):
return "{SHA512-CRYPT}" + raw
# MD5-CRYPT ($1$), bcrypt ($2$/$2a$/$2y$), DES — non migrables
return None
# ── Migration ─────────────────────────────────────────────────────────────
def migrate_domains(src, tgt, dry_run):
with src.cursor() as cur:
cur.execute("SELECT * FROM domain WHERE domain != 'ALL'")
rows = cur.fetchall()
log.info(f"Domaines trouvés : {len(rows)}")
ok = skip = 0
for row in rows:
domain = row["domain"]
# PostfixAdmin quota est en Mo (quota global du domaine)
max_quota = int(row.get("quota", 0))
# maxquota = quota par BAL en Mo
mb_quota = int(row.get("maxquota", 0))
# aliases = limite d'alias par domaine
max_aliases = int(row.get("aliases", 0))
data = {
"domain": domain,
"description": row.get("description", ""),
"max_accounts": row.get("mailboxes", 0),
"max_aliases": max_aliases,
"max_quota_mb": max_quota,
"mb_quota_mb": mb_quota,
"active": int(row.get("active", 1)),
"created": row.get("created", datetime.now()),
"modified": row.get("modified", datetime.now()),
}
if dry_run:
log.info(f" [DRY] domain: {domain} (max_accounts={data['max_accounts']})")
ok += 1
continue
try:
with tgt.cursor() as cur:
cur.execute("""
INSERT INTO domain
(domain, description, max_accounts, max_aliases, max_quota_mb, mb_quota_mb,
active, created, modified)
VALUES (%(domain)s, %(description)s, %(max_accounts)s, %(max_aliases)s,
%(max_quota_mb)s, %(mb_quota_mb)s, %(active)s, %(created)s, %(modified)s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
max_accounts=VALUES(max_accounts),
max_aliases=VALUES(max_aliases),
max_quota_mb=VALUES(max_quota_mb),
mb_quota_mb=VALUES(mb_quota_mb),
modified=NOW()
""", data)
tgt.commit()
ok += 1
except Exception as e:
log.warning(f" domaine {domain}: {e}")
skip += 1
log.info(f"Domaines : {ok} migrés, {skip} ignorés")
def migrate_mailboxes(src, tgt, dry_run):
with src.cursor() as cur:
cur.execute("SELECT * FROM mailbox")
rows = cur.fetchall()
log.info(f"Boîtes trouvées : {len(rows)}")
ok = skip = no_pwd = 0
for row in rows:
username = row["username"]
newpassword = row.get("newpassword", "")
raw_pwd = row.get("password", "")
# Priorité : newpassword (déjà SHA512) s'il est rempli
if newpassword and newpassword.strip():
password = normalize_password(newpassword.strip())
if password is None:
password = normalize_password(raw_pwd)
else:
password = normalize_password(raw_pwd)
if password is None:
log.warning(f" {username}: mot de passe non migrable ({raw_pwd[:10]}...) — compte marqué MIGRATION_REQUIRED")
password = "{MIGRATION_REQUIRED}"
no_pwd += 1
# PostfixAdmin mailbox quota est en octets (ex: 10485760000 ≈ 10 Go)
# NetAdmin utilise les Mo → diviser par 1024*1024
quota_raw = int(row.get("quota", 0))
quota_mb = quota_raw // (1024 * 1024) if quota_raw > 0 else 10000
if quota_mb == 0:
quota_mb = 10000 # défaut 10 Go (= 10000 Mo comme PostfixAdmin)
# Désactiver les comptes dont le mot de passe n'a pas pu être migré
active = 0 if password == "{MIGRATION_REQUIRED}" else int(row.get("active", 1))
data = {
"username": username,
"password": password,
"name": row.get("name", ""),
"domain": row["domain"],
"quota_mb": quota_mb,
"active": active,
"created": row.get("created", datetime.now()),
"modified": row.get("modified", datetime.now()),
}
if dry_run:
pwd_status = "OK" if not password.startswith("{MIGRATION_REQUIRED}") else "RESET REQUIS"
log.info(f" [DRY] mailbox: {username} ({pwd_status}, quota={quota_mb}Mo)")
ok += 1
continue
try:
with tgt.cursor() as cur:
cur.execute("""
INSERT INTO mailbox
(username, password, name, domain, quota_mb, active, created, modified)
VALUES (%(username)s, %(password)s, %(name)s, %(domain)s,
%(quota_mb)s, %(active)s, %(created)s, %(modified)s)
ON DUPLICATE KEY UPDATE
password=VALUES(password), name=VALUES(name),
quota_mb=VALUES(quota_mb), modified=NOW()
""", data)
# Self-alias requis pour virtual_alias_maps
cur.execute("""
INSERT INTO alias (address, goto, domain, active, created, modified)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE goto=VALUES(goto), modified=NOW()
""", (username, username, data["domain"],
data["active"], data["created"], data["modified"]))
tgt.commit()
ok += 1
except Exception as e:
log.warning(f" mailbox {username}: {e}")
skip += 1
log.info(f"Boîtes : {ok} migrées ({no_pwd} avec reset requis), {skip} ignorées")
def migrate_aliases(src, tgt, dry_run):
with src.cursor() as cur:
cur.execute("SELECT * FROM alias")
rows = cur.fetchall()
log.info(f"Alias trouvés : {len(rows)}")
ok = skip = ignored = 0
for row in rows:
address = row["address"]
goto = row.get("goto", "")
# Ignorer les alias auto (address == goto) — déjà créés avec la BAL
if address == goto:
ignored += 1
continue
# Ignorer les catch-all vides
if not goto.strip():
ignored += 1
continue
# Ignorer le domaine ALL (PostfixAdmin)
if row.get("domain") == "ALL":
ignored += 1
continue
data = {
"address": address,
"goto": goto,
"domain": row["domain"],
"active": int(row.get("active", 1)),
"created": row.get("created", datetime.now()),
"modified": row.get("modified", datetime.now()),
}
if dry_run:
log.info(f" [DRY] alias: {address}{goto[:60]}")
ok += 1
continue
try:
with tgt.cursor() as cur:
cur.execute("""
INSERT INTO alias
(address, goto, domain, active, created, modified)
VALUES (%(address)s, %(goto)s, %(domain)s,
%(active)s, %(created)s, %(modified)s)
ON DUPLICATE KEY UPDATE
goto=VALUES(goto), modified=NOW()
""", data)
tgt.commit()
ok += 1
except Exception as e:
log.warning(f" alias {address}: {e}")
skip += 1
log.info(f"Alias : {ok} migrés, {ignored} ignorés (auto/vides), {skip} erreurs")
# ── Main ──────────────────────────────────────────────────────────────────
def main():
args = get_args()
if args.dry_run:
log.info("=== MODE DRY-RUN — aucune écriture ===")
# Connexion source
log.info(f"Connexion source: {args.source_db}@{args.host}")
src = connect(args, args.source_db)
if not args.dry_run:
# Créer la base cible si nécessaire
admin = connect(args)
with admin.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{args.target_db}` "
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
admin.commit()
admin.close()
log.info(f"Base {args.target_db} créée ✓")
tgt = connect(args, args.target_db)
create_schema(tgt)
else:
tgt = None
# Migration
migrate_domains(src, tgt, args.dry_run)
migrate_mailboxes(src, tgt, args.dry_run)
migrate_aliases(src, tgt, args.dry_run)
src.close()
if tgt:
tgt.close()
log.info("=== Migration terminée ===")
if args.dry_run:
log.info("Relancez sans --dry-run pour appliquer.")
if __name__ == "__main__":
main()