Flowdeck 7.85.5 — inscription locale fermée par défaut (fusion #146) + échec de connexion nommant la voie ObsiGate SSO (fusion #144)
FlowDeck CI / docker (push) Blocked by required conditions
FlowDeck CI / test (push) In progress
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / lint (pull_request) Failing after 3s
FlowDeck CI / test (pull_request) Failing after 35m42s
FlowDeck CI / docker (pull_request) Skipped

This commit is contained in:
Spark
2026-11-07 04:29:23 +00:00
parent 8915c7ddd1
commit 931e4d740b
10 changed files with 451 additions and 9 deletions
+7
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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).
+18 -2
View File
@@ -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)
+113
View File
@@ -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) »."
)
+2 -2
View File
@@ -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>