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
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:
@@ -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) »."
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user