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:
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,3 +9,4 @@ qrcode>=7.4.2
|
||||
pillow>=10.0.0
|
||||
|
||||
PyMySQL>=1.1.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
|
||||
+197
-151
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 '%@%';
|
||||
@@ -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 '%@%@%';
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 :
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user