Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b91f5f12 | ||
|
|
31fe0c181a | ||
|
|
931e4d740b | ||
|
|
8915c7ddd1 | ||
|
|
0c3b6c522f | ||
|
|
6ce408059c | ||
|
|
fcc6cdf830 |
@@ -1,3 +1,22 @@
|
||||
## v7.85.5 (2026-11-06) — Inscription locale fermée par défaut + échec de connexion qui nomme la voie SSO (fusion #146, #144)
|
||||
|
||||
- **#146 — l'inscription publique locale n'est plus ouverte par défaut** : nouveau réglage d'administration « inscription locale ouverte » (`app/services/reglages_acces.py`, patron `llm_config`/`sso_config` : ligne unique en base qui prime sur la variable `INSCRIPTION_LOCALE_OUVERTE`, défaut **fermée** — décision de Bruno : les comptes naissent dans le Hub Dracodev). Réversible à tout moment par un administrateur via `GET`/`PUT /api/admin/reglages-acces`.
|
||||
- **Quand elle est fermée** : `POST /auth/register` répond 403 et `GET /auth/register` affiche une page de refus propre en français — les comptes se créent dans le Hub Dracodev, avec le lien du Hub dérivé de l'hôte (`draco_urls.hub_url()`, comme les autres liens de la suite) ; l'onglet Register disparaît de l'écran de connexion (avis nommant le Hub à la place) et les boutons « Get started » de la page d'accueil cèdent la place au lien Hub. **La connexion des comptes existants, locaux comme draco, n'est jamais affectée.**
|
||||
- **#144 (part Flowdeck)** : l'échec du formulaire de connexion locale (« Invalid credentials », 401) suggère désormais la voie centrale : « Si votre compte a été créé dans le Hub Dracodev, connectez-vous avec le bouton « Login with ObsiGate (SSO) »… » — le bouton existant, dont le libellé est repris tel quel. Aucune logique d'authentification ne change.
|
||||
- **Tests** : `tests/test_reglages_acces.py` (nouveau, 12 tests) — fermée par défaut (défaut du code vérifié), POST refusé 403 + message + aucun compte créé, page de refus, onglet Register absent, ouverte = inscription d'aujourd'hui, connexion des comptes existants (local et draco) inchangée ouvert/fermé, échec local nommant ObsiGate (SSO), aller-retour de l'API d'administration et refus aux anonymes. Le harnais de test ouvre explicitement l'inscription pour les suites historiques qui créent leurs comptes par `/auth/register` (conftest et les fixtures locales des suites historiques, commenté).
|
||||
|
||||
## v7.85.4 (2026-11-06) — Barre commune v0.6.0 re-vendangée : entrée « Administration » (fusion #142)
|
||||
|
||||
- **Re-vendange verbatim** de la barre commons v0.6.0 (PR dracodev-commons #14) : `static/js/vendor/draco-barre.js` + `static/css/draco-barre.css`. Le menu compte gagne l'entrée **Administration** → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), à **visibilité conditionnelle** (affirmée par l'hôte via `cfg.admin` / `DRACO_BAR.setAdmin`, jamais déduite par la barre).
|
||||
- **Pas de drapeau chez Flowdeck** : Flowdeck ne connaît que le rôle du registre du Hub pour sa propre application (consulté au pont draco avant le JIT) — pas le rôle global de suite (rôle du compte chez ObsiGate, la définition de la garde du Hub). À défaut de certitude, le contrat v0.6.0 impose de ne pas affirmer : l'entrée reste masquée dans Flowdeck (elle apparaît chez ObsiGate et dans la console, qui peuvent l'établir). Commentaire posé dans le gabarit `_draco_bar.html`.
|
||||
- **Test** : `tests/test_draco_bar_texte.py::test_barre_v060_entree_administration_vendangee` (marqueurs de la vendange dans les assets servis).
|
||||
|
||||
## v7.85.3 (2026-11-06) — Déconnexion du menu compte branchée (fusion #141)
|
||||
|
||||
- **Défaut corrigé** : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien — la barre relaie l'action `deconnexion` à l'hôte et le `onAction` du gabarit (`app/templates/_draco_bar.html`) n'avait aucune branche pour elle (même défaut que la console Spark, l'hôte du signalement de Bruno).
|
||||
- **Branchement** : nouvelle branche `deconnexion` — destruction d'abord de la session locale Flowdeck (`fetch GET /auth/logout`, le même point de sortie que les liens « Log out » existants), puis navigation vers la déconnexion globale de l'émettrice (`GET {draco_urls().obsigate}/sso/logout?return_to={origine}/`, nouvelle route ObsiGate 2.86.0, PR sœur) : le `jti` draco y est révoqué et le cookie effacé sur le domaine parent. Après le parcours, la session de la suite entière et la session locale Flowdeck sont terminées ; le retour se fait sur la racine Flowdeck (page de connexion).
|
||||
- **Test** : `tests/test_draco_bar_texte.py::test_barre_deconnexion_branchee_globale` (marqueurs du branchement dans le gabarit). Périmètre draco/barre comparé à main vierge dans le même environnement : fonds identique (2 échecs `test_draco_urls_devprod` et 17 erreurs de dépendances absentes de l'environnement local, préexistants), +1 test passant.
|
||||
|
||||
## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a))
|
||||
|
||||
- **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement.
|
||||
|
||||
@@ -46,6 +46,13 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
default_lang: str = "fr"
|
||||
|
||||
# Réglages d'accès (fusion #146) — défauts repris par
|
||||
# `app/services/reglages_acces.py` ; la ligne enregistrée en base
|
||||
# par un administrateur prime sur ces valeurs.
|
||||
# Inscription publique locale : fermée par défaut (décision de
|
||||
# Bruno — les comptes naissent dans le Hub Dracodev).
|
||||
inscription_locale_ouverte: bool = False
|
||||
|
||||
# Rate limiting
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
@@ -146,6 +146,30 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Réglages d'accès (fusion #146) ──
|
||||
@router.get("/reglages-acces")
|
||||
def get_reglages_acces(_admin=Depends(admin_required)):
|
||||
"""Réglages d'accès effectifs de l'instance (administrateurs)."""
|
||||
from app.services import reglages_acces
|
||||
return reglages_acces.get_reglages_acces()
|
||||
|
||||
|
||||
@router.put("/reglages-acces")
|
||||
def put_reglages_acces(_admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Modifie les réglages d'accès — réversible à tout moment.
|
||||
|
||||
Corps accepté : ``{"inscription_locale_ouverte": true|false}``.
|
||||
Fermée (défaut) : aucun compte local ne se crée sur l'instance,
|
||||
les comptes naissent dans le Hub Dracodev. La connexion des
|
||||
comptes existants n'est jamais affectée.
|
||||
"""
|
||||
from app.services import reglages_acces
|
||||
if "inscription_locale_ouverte" in body:
|
||||
return reglages_acces.set_reglages_acces(
|
||||
inscription_locale_ouverte=bool(body["inscription_locale_ouverte"]))
|
||||
return reglages_acces.get_reglages_acces()
|
||||
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
def user_stats(_admin=Depends(admin_required)):
|
||||
|
||||
+94
-4
@@ -51,6 +51,76 @@ def _sso_login_url() -> str:
|
||||
f"?return_to={quote(retour, safe='')}")
|
||||
|
||||
|
||||
# Échec de connexion locale (fusion #144) : le message suggère la voie
|
||||
# centrale — un compte créé dans le Hub Dracodev n'a pas de mot de
|
||||
# passe local, son entrée est le bouton « Login with ObsiGate (SSO) »
|
||||
# du même écran (libellé repris tel quel).
|
||||
MESSAGE_ECHEC_CONNEXION_LOCALE = (
|
||||
"Identifiants invalides. Si votre compte a été créé dans le Hub "
|
||||
"Dracodev, connectez-vous avec le bouton « Login with ObsiGate "
|
||||
"(SSO) » de cet écran : les comptes de la suite n'ont pas de mot "
|
||||
"de passe local."
|
||||
)
|
||||
|
||||
|
||||
def _page_inscription_fermee() -> str:
|
||||
"""Page de refus propre du parcours d'inscription fermé (#146)."""
|
||||
from app.services import draco_urls, reglages_acces
|
||||
|
||||
hub = draco_urls.hub_url()
|
||||
message = reglages_acces.message_inscription_fermee()
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — Inscription fermée</title>
|
||||
<style>
|
||||
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:420px;}}
|
||||
.box h1{{font-size:22px;margin:0 0 12px;}}
|
||||
.box p{{color:rgba(255,255,255,.6);font-size:14px;line-height:1.6;margin:0 0 16px;}}
|
||||
.box a{{color:#2383E2;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Inscription fermée</h1>
|
||||
<p>{message}</p>
|
||||
<p><a href="{hub}">Ouvrir le Hub Dracodev</a> ·
|
||||
<a href="/auth/login?provider=local">Retour à la connexion</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _rendre_page_connexion(html: str) -> str:
|
||||
"""Rend la page de connexion en tenant compte du réglage #146.
|
||||
|
||||
Inscription fermée : l'onglet Register disparaît et un avis nomme
|
||||
le Hub Dracodev (lien dérivé de l'hôte, comme les autres liens de
|
||||
la suite). Inscription ouverte : page intégralement inchangée.
|
||||
"""
|
||||
from app.services import draco_urls, reglages_acces
|
||||
|
||||
if reglages_acces.inscription_locale_ouverte():
|
||||
return _with_nonce(html)
|
||||
html = html.replace(
|
||||
"""<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>""",
|
||||
"",
|
||||
)
|
||||
avis = (
|
||||
'<div class="success" style="display:block;'
|
||||
'background:rgba(35,131,226,.15);color:#7db8f0">'
|
||||
"Pas encore de compte ? Les comptes se créent dans le "
|
||||
f'<a href="{draco_urls.hub_url()}" style="color:#7db8f0">'
|
||||
"Hub Dracodev</a>, puis se connectent ici avec le bouton "
|
||||
"« Login with ObsiGate (SSO) ».</div>"
|
||||
)
|
||||
html = html.replace('<div id="expired-msg"', avis + '<div id="expired-msg"', 1)
|
||||
return _with_nonce(html)
|
||||
|
||||
|
||||
def _with_nonce(html: str) -> str:
|
||||
"""A20 : injecte le nonce CSP au moment du rendu.
|
||||
|
||||
@@ -180,7 +250,15 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
|
||||
|
||||
@router.get("/register")
|
||||
def register_page(request: Request):
|
||||
"""Show the registration page (local login page with register tab active)."""
|
||||
"""Show the registration page (local login page with register tab active).
|
||||
|
||||
Inscription fermée (#146, défaut) : refus propre — page en
|
||||
français nommant le Hub Dracodev, là où les comptes se créent.
|
||||
"""
|
||||
from app.services import reglages_acces
|
||||
|
||||
if not reglages_acces.inscription_locale_ouverte():
|
||||
return HTMLResponse(_page_inscription_fermee(), status_code=403)
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
|
||||
'class="tab active" onclick="switchTab(\'login\')"',
|
||||
'class="tab" onclick="switchTab(\'login\')"'
|
||||
@@ -205,7 +283,7 @@ def login(request: Request, provider: str = Query("gitea")):
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
|
||||
return HTMLResponse(_rendre_page_connexion(LOCAL_LOGIN_HTML), status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
@@ -259,6 +337,18 @@ def register(request: Request, body: dict = Body(default={})):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
# Inscription locale fermée (fusion #146, défaut) : les comptes
|
||||
# naissent dans le Hub Dracodev — aucun compte local ne se crée
|
||||
# ici. La connexion des comptes existants n'est pas touchée.
|
||||
from app.services import draco_urls, reglages_acces
|
||||
if not reglages_acces.inscription_locale_ouverte():
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"error": reglages_acces.message_inscription_fermee(),
|
||||
"hub_url": draco_urls.hub_url()},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# SSO-only instance (v6.7.0): local registration is refused — accounts are
|
||||
# auto-provisioned by the IdP instead (admins still come from Settings).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
@@ -313,7 +403,7 @@ def local_login(request: Request, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
return JSONResponse({"error": MESSAGE_ECHEC_CONNEXION_LOCALE}, status_code=401)
|
||||
|
||||
ud = dict(user)
|
||||
if not ud.get("is_active"):
|
||||
@@ -332,7 +422,7 @@ def local_login(request: Request, body: dict = Body(default={})):
|
||||
(attempts, lock, ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
return JSONResponse({"error": MESSAGE_ECHEC_CONNEXION_LOCALE}, status_code=401)
|
||||
|
||||
# Successful local login — SSO-only instances keep a way in for admins
|
||||
# only (every other account must use the IdP, design §7.1).
|
||||
|
||||
@@ -27,6 +27,22 @@ router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
def _contexte_accueil() -> dict:
|
||||
"""Contexte de la page d'accueil publique (fusion #146).
|
||||
|
||||
Inscription fermée (défaut) : les boutons « Get started » de
|
||||
l'accueil cèdent la place à un lien vers le Hub Dracodev, là où
|
||||
les comptes se créent (URL dérivée de l'hôte, comme les autres
|
||||
liens de la suite).
|
||||
"""
|
||||
from app.services import draco_urls, reglages_acces
|
||||
|
||||
return {
|
||||
"inscription_ouverte": reglages_acces.inscription_locale_ouverte(),
|
||||
"hub_url": draco_urls.hub_url(),
|
||||
}
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
@@ -46,13 +62,13 @@ async def dashboard(
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
return template.render(**_contexte_accueil())
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
return template.render(**_contexte_accueil())
|
||||
|
||||
# ── Authenticated ──
|
||||
user_id = user.get("id", 1)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Réglages d'accès de l'instance — inscription locale (fusion #146).
|
||||
|
||||
Patron des réglages existants (``llm_config``, ``sso_config``) : une
|
||||
table à ligne unique (``id = 1``) créée paresseusement, qui prime sur
|
||||
les valeurs de ``app.config.settings`` (variables d'environnement /
|
||||
``.env``), elles-mêmes aux défauts du code.
|
||||
|
||||
Réglage « inscription locale ouverte » :
|
||||
|
||||
- **fermée par défaut** (décision de Bruno, programme « compte
|
||||
partout ») : les comptes naissent dans le Hub Dracodev et entrent
|
||||
dans Flowdeck par la voie ObsiGate (SSO) / le provisionnement JIT ;
|
||||
le parcours public d'inscription locale est refusé proprement.
|
||||
- réversible à tout moment par un administrateur de l'instance
|
||||
(``GET``/``PUT /api/admin/reglages-acces``) ou en posant
|
||||
``INSCRIPTION_LOCALE_OUVERTE=true`` avant tout enregistrement en
|
||||
base.
|
||||
|
||||
Ce réglage ne touche **jamais** la connexion des comptes existants,
|
||||
locaux comme draco : il ne garde que la création de comptes locaux.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"get_reglages_acces",
|
||||
"set_reglages_acces",
|
||||
"inscription_locale_ouverte",
|
||||
"message_inscription_fermee",
|
||||
]
|
||||
|
||||
|
||||
def _assurer_table(conn) -> None:
|
||||
"""Crée la table des réglages d'accès si elle n'existe pas encore."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reglages_acces (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
inscription_locale_ouverte INTEGER,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def get_reglages_acces() -> dict:
|
||||
"""Réglages d'accès effectifs — la ligne en base prime sur la config."""
|
||||
reglages = {
|
||||
# Défaut du code et de la config : inscription fermée.
|
||||
"inscription_locale_ouverte": bool(
|
||||
getattr(settings, "inscription_locale_ouverte", False)),
|
||||
}
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
_assurer_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT inscription_locale_ouverte FROM reglages_acces"
|
||||
" WHERE id=1"
|
||||
).fetchone()
|
||||
except Exception: # noqa: BLE001 — base pas encore prête → config seule
|
||||
return reglages
|
||||
if row is not None and row["inscription_locale_ouverte"] is not None:
|
||||
reglages["inscription_locale_ouverte"] = bool(
|
||||
row["inscription_locale_ouverte"])
|
||||
return reglages
|
||||
|
||||
|
||||
def set_reglages_acces(*, inscription_locale_ouverte: bool | None = None) -> dict:
|
||||
"""Enregistre les réglages d'accès (ligne unique) et les renvoie."""
|
||||
from app.db import get_conn
|
||||
|
||||
actuels = get_reglages_acces()
|
||||
if inscription_locale_ouverte is not None:
|
||||
actuels["inscription_locale_ouverte"] = bool(inscription_locale_ouverte)
|
||||
with get_conn() as conn:
|
||||
_assurer_table(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO reglages_acces (id, inscription_locale_ouverte,"
|
||||
" updated_at) VALUES (1, ?, CURRENT_TIMESTAMP)"
|
||||
" ON CONFLICT(id) DO UPDATE SET"
|
||||
" inscription_locale_ouverte=excluded.inscription_locale_ouverte,"
|
||||
" updated_at=CURRENT_TIMESTAMP",
|
||||
(1 if actuels["inscription_locale_ouverte"] else 0,),
|
||||
)
|
||||
conn.commit()
|
||||
return actuels
|
||||
|
||||
|
||||
def inscription_locale_ouverte() -> bool:
|
||||
"""Vrai si l'inscription publique locale est ouverte sur l'instance."""
|
||||
return bool(get_reglages_acces()["inscription_locale_ouverte"])
|
||||
|
||||
|
||||
def message_inscription_fermee() -> str:
|
||||
"""Message affiché quand l'inscription locale est fermée (#146).
|
||||
|
||||
Nomme le Hub Dracodev — là où les comptes se créent — avec son URL
|
||||
dérivée de l'hôte comme les autres liens de la suite
|
||||
(``draco_urls.hub_url()``, jamais un nom figé).
|
||||
"""
|
||||
from app.services import draco_urls
|
||||
|
||||
return (
|
||||
"L'inscription locale est fermée sur cette instance : "
|
||||
"les comptes se créent dans le Hub Dracodev "
|
||||
f"({draco_urls.hub_url()}), puis se connectent ici avec le "
|
||||
"bouton « Login with ObsiGate (SSO) »."
|
||||
)
|
||||
@@ -113,6 +113,14 @@
|
||||
dérivation par l'agent, défaut officiel) alimente les
|
||||
liens du menu compte (Télécharger, À Propos, Aides,
|
||||
Profil) via urls.hub. */
|
||||
/* Barre v0.6.0 (fusion #142) : JS/CSS re-vendangés verbatim
|
||||
— le menu compte gagne l'entrée « Administration »
|
||||
(lien Hub <hub>/admin) à visibilité conditionnelle.
|
||||
Flowdeck ne passe PAS le drapeau admin : il ne connaît
|
||||
que le rôle du registre du Hub pour sa propre app
|
||||
(consulté au pont draco), pas le rôle global de suite
|
||||
(rôle du compte chez ObsiGate) — à défaut de certitude,
|
||||
l'entrée reste masquée ici, comme le contrat l'exige. */
|
||||
urls: { hub: "{{ draco_urls().hub }}/" },
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
counts: {},
|
||||
@@ -132,6 +140,20 @@
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
if (act === "deconnexion") {
|
||||
/* Fusion #141 : détruire d'abord la session locale
|
||||
Flowdeck (GET /auth/logout), puis naviguer vers la
|
||||
déconnexion globale de l'émettrice (ObsiGate,
|
||||
GET /sso/logout) avec retour ici — le jti draco y
|
||||
est révoqué et le cookie effacé sur le domaine
|
||||
parent : la session de la suite entière se termine. */
|
||||
var retourFd = encodeURIComponent(location.origin + "/");
|
||||
var cibleFd = "{{ draco_urls().obsigate }}/sso/logout?return_to=" + retourFd;
|
||||
fetch("/auth/logout", { credentials: "same-origin" })
|
||||
.catch(function () { })
|
||||
.finally(function () { location.href = cibleFd; });
|
||||
return;
|
||||
}
|
||||
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). Nonce CSP du script inchangé. */
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
<a href="/auth/register" class="btn btn-primary">Get started free</a>
|
||||
{% if inscription_ouverte %}<a href="/auth/register" class="btn btn-primary">Get started free</a>{% else %}<a href="{{ hub_url }}" class="btn btn-primary">Créer un compte dans le Hub Dracodev</a>{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
All on your own server.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
|
||||
{% if inscription_ouverte %}<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>{% else %}<a href="{{ hub_url }}" class="btn btn-primary">Créer un compte dans le Hub Dracodev</a>{% endif %}
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||
/* Item conditionnel masqué (v0.6.0 : « Administration » tant que
|
||||
l'hôte n'a pas affirmé le statut d'administrateur) — le
|
||||
display:flex ci-dessus écraserait l'attribut hidden sans cette
|
||||
règle, même patron que .draco-compte-menu[hidden]. */
|
||||
.draco-menu-item[hidden] { display: none; }
|
||||
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
|
||||
libellé dans le DOM, replacées à gauche par order: -1. */
|
||||
.draco-menu-icone {
|
||||
|
||||
Vendored
+67
@@ -42,6 +42,22 @@
|
||||
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
|
||||
* local court et repli intégral sur le comportement actuel de
|
||||
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
|
||||
* v0.6.0 (demande de Bruno, 2026-11-06) : le menu compte gagne un
|
||||
* cinquième lien Hub, « Administration » → <hub>/admin (le Hub y
|
||||
* redirige vers /admin/utilisateurs), placé après le séparateur,
|
||||
* avant « Déconnexion », avec son icône monoline (curseurs de
|
||||
* réglage). AFFICHAGE CONDITIONNEL : l'entrée n'apparaît que si
|
||||
* l'hôte AFFIRME que le compte courant est administrateur de
|
||||
* suite — cfg.admin === true à la configuration, ou
|
||||
* DRACO_BAR.setAdmin(true) quand l'hôte l'apprend après coup
|
||||
* (patron setUrls). L'hôte ne passe le drapeau que s'il l'a établi
|
||||
* depuis SA source d'autorité (rôle global du compte chez
|
||||
* ObsiGate, niveau du registre des rôles du Hub pour son app…) ;
|
||||
* à défaut de certitude, pas de drapeau et l'entrée reste masquée.
|
||||
* La barre ne devine jamais : le jeton draco ne porte aucun rôle
|
||||
* (claims minimaux, par design). La vraie protection reste la
|
||||
* garde serveur du Hub (/admin/*), pas ce masquage d'affichage.
|
||||
* Voir le README, « Administration » — contrat de visibilité.
|
||||
*
|
||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
@@ -54,6 +70,11 @@
|
||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||
* // ou { display_name: "Bruno", username: "bruno" }
|
||||
* admin: true, // (v0.6.0, optionnel)
|
||||
* // administrateur de suite ÉTABLI par l'hôte : démasque
|
||||
* // l'entrée « Administration » du menu compte. Absent ou
|
||||
* // non-strictement-true → entrée masquée. Peut aussi être
|
||||
* // posé après coup via DRACO_BAR.setAdmin(true).
|
||||
* urls: { // bases des applications
|
||||
* obsigate: "https://og.dracodev.net/",
|
||||
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||
@@ -653,6 +674,18 @@
|
||||
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
|
||||
["circle", { cx: "12", cy: "7", r: "4" }]
|
||||
],
|
||||
/* Curseurs de réglage (trois axes, trois boutons) — v0.6.0. */
|
||||
"admin": [
|
||||
["line", { x1: "4", y1: "21", x2: "4", y2: "14" }],
|
||||
["line", { x1: "4", y1: "10", x2: "4", y2: "3" }],
|
||||
["line", { x1: "12", y1: "21", x2: "12", y2: "12" }],
|
||||
["line", { x1: "12", y1: "8", x2: "12", y2: "3" }],
|
||||
["line", { x1: "20", y1: "21", x2: "20", y2: "16" }],
|
||||
["line", { x1: "20", y1: "12", x2: "20", y2: "3" }],
|
||||
["line", { x1: "1", y1: "14", x2: "7", y2: "14" }],
|
||||
["line", { x1: "9", y1: "8", x2: "15", y2: "8" }],
|
||||
["line", { x1: "17", y1: "16", x2: "23", y2: "16" }]
|
||||
],
|
||||
/* Flèche sortant d'un cadre (porte). */
|
||||
"deconnexion": [
|
||||
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
|
||||
@@ -687,6 +720,15 @@
|
||||
return span;
|
||||
}
|
||||
|
||||
/* Visibilité de l'entrée « Administration » (v0.6.0) : vraie
|
||||
* seulement si l'hôte l'a AFFIRMÉE — cfg.admin === true à la
|
||||
* configuration, ou DRACO_BAR.setAdmin(true) après coup. La
|
||||
* barre ne déduit jamais ce statut (le jeton draco ne porte
|
||||
* aucun rôle) ; à défaut, l'entrée reste masquée et la garde
|
||||
* serveur du Hub reste la vraie protection. */
|
||||
var compteAdmin = cfg.admin === true;
|
||||
var adminItem = null;
|
||||
|
||||
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
|
||||
* d'identité, puis les liens du Hub (Télécharger, À Propos,
|
||||
* Aides, Profil — vrais <a href>, même patron que le menu des
|
||||
@@ -719,6 +761,22 @@
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
compteMenu.appendChild(sep);
|
||||
/* « Administration » (v0.6.0) : cinquième lien Hub
|
||||
* (<hub>/admin, où le Hub redirige vers /admin/utilisateurs).
|
||||
* data-hub-link="admin" : son href est resynchronisé par
|
||||
* refreshCompteHrefs / setUrls comme les quatre autres liens.
|
||||
* Toujours construit ; masqué tant que l'hôte n'a pas affirmé
|
||||
* le statut d'administrateur (compteAdmin). */
|
||||
var adm = document.createElement("a");
|
||||
adm.className = "draco-menu-item";
|
||||
adm.href = hubHref("admin");
|
||||
adm.setAttribute("data-hub-link", "admin");
|
||||
adm.setAttribute("role", "menuitem");
|
||||
adm.textContent = "Administration";
|
||||
adm.appendChild(menuIcone("admin"));
|
||||
adm.hidden = !compteAdmin;
|
||||
compteMenu.appendChild(adm);
|
||||
adminItem = adm;
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "draco-menu-item";
|
||||
@@ -734,6 +792,7 @@
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
if (adminItem) adminItem.hidden = !compteAdmin;
|
||||
refreshCompteHrefs();
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
@@ -835,6 +894,14 @@
|
||||
window.DRACO_BAR.urls = resolvedUrls;
|
||||
applyUrls(resolvedUrls);
|
||||
refreshCompteHrefs();
|
||||
},
|
||||
/* Administration (v0.6.0) : l'hôte pose le drapeau quand il a
|
||||
* ÉTABLI que le compte courant est administrateur de suite
|
||||
* (sa source d'autorité — voir l'en-tête et le README). Toute
|
||||
* valeur autre que le booléen true masque l'entrée. */
|
||||
setAdmin: function (v) {
|
||||
compteAdmin = v === true;
|
||||
if (adminItem) adminItem.hidden = !compteAdmin;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -127,6 +127,14 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
# Fusion #146 : l'inscription locale est fermée par défaut en
|
||||
# production (les comptes naissent au Hub). L'environnement de
|
||||
# test l'ouvre explicitement pour les suites historiques qui
|
||||
# créent leurs comptes par /auth/register ; les tests du réglage
|
||||
# (tests/test_reglages_acces.py) la ferment eux-mêmes.
|
||||
from app.services import reglages_acces
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
# Cleanup
|
||||
|
||||
@@ -31,6 +31,12 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
# Fusion #146 : inscription fermée par défaut en production —
|
||||
# cette suite historique crée ses comptes par /auth/register,
|
||||
# elle ouvre donc le réglage explicitement (comme conftest).
|
||||
from app.services import reglages_acces
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
|
||||
# A default user id=1 so tests that reference the implicit user (favorites,
|
||||
# workspace ownership) satisfy foreign keys without relying on leaked state.
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -33,6 +33,35 @@ def test_aucun_texte_chantier_rendu():
|
||||
assert rendu.lstrip().startswith("<link")
|
||||
|
||||
|
||||
def test_barre_deconnexion_branchee_globale():
|
||||
"""Déconnexion du menu compte (fusion #141) : le gabarit branche
|
||||
l'action « deconnexion » — destruction de la session locale
|
||||
(/auth/logout) puis navigation vers la déconnexion globale de
|
||||
l'émettrice (GET /sso/logout chez ObsiGate, retour ici). Avant le
|
||||
correctif, aucune branche ne traitait l'action : clic sans effet."""
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
assert 'act === "deconnexion"' in gabarit
|
||||
assert 'fetch("/auth/logout"' in gabarit
|
||||
assert '{{ draco_urls().obsigate }}/sso/logout?return_to=' in gabarit
|
||||
|
||||
|
||||
def test_barre_v060_entree_administration_vendangee():
|
||||
"""Barre v0.6.0 (fusion #142) : JS/CSS vendangés portent l'entrée
|
||||
« Administration » du menu compte (lien Hub data-hub-link
|
||||
« admin », setAdmin, règle CSS [hidden]) ; le gabarit documente
|
||||
pourquoi Flowdeck ne passe pas le drapeau (pas de rôle global
|
||||
connu) — aucun « admin: true » dans la configuration servie."""
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert 'setAttribute("data-hub-link", "admin")' in js
|
||||
assert '"Administration"' in js
|
||||
assert "setAdmin" in js
|
||||
css = (ROOT / "static/css/draco-barre.css").read_text()
|
||||
assert ".draco-menu-item[hidden]" in css
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
assert "v0.6.0" in gabarit
|
||||
assert "admin: true" not in gabarit
|
||||
|
||||
|
||||
def test_barre_v020_onglet_shaarit_et_garde_data_theme():
|
||||
"""Barre v0.2.0 (commons @70c837a) : onglet ShaarIt entre Kanbante
|
||||
et l'onglet agent, logo fourni tel quel, CSS vendangé verbatim
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""FlowDeck — réglages d'accès : inscription locale (fusion #146, #144).
|
||||
|
||||
Contrat de la phase 1 du programme « compte partout » :
|
||||
|
||||
- inscription locale **fermée par défaut** (décision de Bruno) :
|
||||
le parcours public d'inscription est refusé proprement et nomme
|
||||
le Hub Dracodev, là où les comptes se créent ;
|
||||
- réglage **réversible par un administrateur** (API
|
||||
``/api/admin/reglages-acces``) : ouverte, l'inscription redevient
|
||||
exactement celle d'aujourd'hui ;
|
||||
- la connexion des comptes existants (locaux et draco) n'est
|
||||
**jamais** affectée par le réglage ;
|
||||
- #144 : l'échec du formulaire de connexion locale suggère la voie
|
||||
ObsiGate (SSO).
|
||||
|
||||
Le harnais de test ouvre l'inscription pour les suites historiques
|
||||
(voir conftest) ; chaque test pose donc explicitement l'état qu'il
|
||||
vérifie via le service des réglages.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.services import reglages_acces
|
||||
|
||||
|
||||
def _fermer():
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=False)
|
||||
|
||||
|
||||
def _ouvrir():
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
|
||||
|
||||
def _creer_compte_local(login: str, mot_de_passe: str, admin: int = 0) -> None:
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO users (login, full_name, email,"
|
||||
" password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, login, f"{login}@test.dev",
|
||||
hash_password(mot_de_passe), admin),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Réglage fermé : inscription impossible + message ────────────────
|
||||
|
||||
def test_inscription_fermee_par_defaut_dans_le_code():
|
||||
"""Le défaut du code (hors toute ligne en base) est « fermée »."""
|
||||
from app.config import settings
|
||||
|
||||
assert settings.inscription_locale_ouverte is False
|
||||
|
||||
|
||||
def test_inscription_fermee_post_refuse_avec_message_hub(client):
|
||||
_fermer()
|
||||
r = client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123",
|
||||
"name": "Visiteur"})
|
||||
assert r.status_code == 403
|
||||
erreur = r.json()["error"]
|
||||
assert "Hub Dracodev" in erreur
|
||||
# Lien dérivé vers le Hub fourni à l'écran comme aux API.
|
||||
assert r.json()["hub_url"]
|
||||
# Aucun compte n'a été créé.
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE login='[email protected]'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_inscription_fermee_page_refus_propre(client):
|
||||
_fermer()
|
||||
r = anon_csrf(client).get("/auth/register", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert "Hub Dracodev" in r.text
|
||||
assert "les comptes se créent" in r.text
|
||||
|
||||
|
||||
def test_inscription_fermee_page_connexion_sans_onglet_register(client):
|
||||
_fermer()
|
||||
r = anon_csrf(client).get("/auth/login?provider=local")
|
||||
assert r.status_code == 200
|
||||
assert 'id="tab-register"' not in r.text
|
||||
# L'avis nomme le Hub et la voie SSO reste visible.
|
||||
assert "Hub Dracodev" in r.text
|
||||
assert "Login with ObsiGate (SSO)" in r.text
|
||||
|
||||
|
||||
# ── Réglage ouvert : inscription comme aujourd'hui ──────────────────
|
||||
|
||||
def test_inscription_ouverte_post_comme_avant(client):
|
||||
_ouvrir()
|
||||
r = client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123",
|
||||
"name": "Nouveau"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_inscription_ouverte_page_register_affichee(client):
|
||||
_ouvrir()
|
||||
r = anon_csrf(client).get("/auth/register", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert 'id="tab-register"' in r.text
|
||||
|
||||
|
||||
# ── Connexion des comptes existants : jamais affectée ───────────────
|
||||
|
||||
def test_connexion_compte_local_existant_inscription_fermee(client):
|
||||
_creer_compte_local("ancien", "secret123")
|
||||
_fermer()
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "ancien", "password": "secret123"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_connexion_compte_draco_sans_mot_de_passe_inchangee(client):
|
||||
"""Un compte draco (sans mot de passe local) reste refusé en local
|
||||
de la même façon, réglage ouvert ou fermé : le réglage ne change
|
||||
rien à l'authentification."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO users (login, full_name, email,"
|
||||
" password_hash, auth_method) VALUES"
|
||||
" ('draco-42', 'Dan Test', '[email protected]', NULL, 'draco')")
|
||||
conn.commit()
|
||||
for ouvrir in (True, False):
|
||||
reglages_acces.set_reglages_acces(
|
||||
inscription_locale_ouverte=ouvrir)
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "draco-42", "password": "nimporte"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── #144 : l'échec local suggère la voie ObsiGate (SSO) ─────────────
|
||||
|
||||
def test_echec_connexion_locale_suggere_sso(client):
|
||||
_creer_compte_local("quelquun", "secret123")
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "quelquun", "password": "mauvais-mot-de-passe"})
|
||||
assert r.status_code == 401
|
||||
assert "ObsiGate (SSO)" in r.json()["error"]
|
||||
assert "Hub Dracodev" in r.json()["error"]
|
||||
|
||||
|
||||
def test_echec_connexion_compte_inconnu_suggere_sso(client):
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "[email protected]", "password": "nimporte"})
|
||||
assert r.status_code == 401
|
||||
assert "ObsiGate (SSO)" in r.json()["error"]
|
||||
|
||||
|
||||
# ── Administration du réglage ────────────────────────────────────────
|
||||
|
||||
def test_api_admin_reglages_acces_aller_retour(client):
|
||||
"""Le client de test est administrateur : lecture + écriture."""
|
||||
_fermer()
|
||||
r = client.get("/api/admin/reglages-acces")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["inscription_locale_ouverte"] is False
|
||||
r = client.put("/api/admin/reglages-acces",
|
||||
json={"inscription_locale_ouverte": True})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["inscription_locale_ouverte"] is True
|
||||
assert reglages_acces.inscription_locale_ouverte() is True
|
||||
|
||||
|
||||
def test_api_admin_reglages_acces_refusee_aux_anonymes(client):
|
||||
r = anon_csrf(client).get("/api/admin/reglages-acces")
|
||||
assert r.status_code in (401, 403)
|
||||
@@ -42,6 +42,11 @@ def client():
|
||||
settings.agent_run_timeout_seconds = 30
|
||||
|
||||
init_db()
|
||||
# Fusion #146 : inscription fermée par défaut en production —
|
||||
# _token() crée ses comptes par /auth/register, la suite ouvre
|
||||
# donc le réglage explicitement (comme conftest).
|
||||
from app.services import reglages_acces
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "
|
||||
|
||||
Reference in New Issue
Block a user