Corrections des bugs de la connexion à Frisbii #90

This commit is contained in:
Colin Maudry
2026-06-25 21:14:35 +02:00
parent 5f500a1aa8
commit e932c66af0
9 changed files with 133 additions and 60 deletions
+3 -3
View File
@@ -59,7 +59,7 @@ BACKUP_ENCRYPTION_KEY=
# Frisbii — gestion des abonnements (https://docs.frisbii.com) # Frisbii — gestion des abonnements (https://docs.frisbii.com)
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
FRISBII_API_BASE_URL= # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer) FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois) FRISBII_PLAN_SIMPLE=abonnement-decp.info # handle du plan "abonnement simple" (20 € HT/mois)
FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois) FRISBII_PLAN_SOUTIEN=soutien-decp.info # handle du plan "abonnement de soutien" (50 € HT/mois)
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+16 -4
View File
@@ -13,6 +13,18 @@ from src.utils import logger
auth_bp = Blueprint("auth", __name__, url_prefix="/auth") auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
def _post_login_url(user_id: int) -> str:
try:
from src.subscriptions import db as sub_db
if sub_db.has_active_subscription(user_id):
return "/compte/admin"
except Exception:
pass
return "/compte/abonnement"
MIN_PASSWORD_LENGTH = 8 MIN_PASSWORD_LENGTH = 8
# Hash bidon pré-calculé pour uniformiser le timing login # Hash bidon pré-calculé pour uniformiser le timing login
@@ -93,7 +105,7 @@ def verify_email():
def login(): def login():
email = (request.form.get("email") or "").strip().lower() email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or "" password = request.form.get("password") or ""
next_url = safe_next(request.form.get("next"), fallback="/compte/admin") next_url = request.form.get("next")
row = db.get_user_by_email(email) row = db.get_user_by_email(email)
if row is None: if row is None:
@@ -109,7 +121,7 @@ def login():
return _redirect_with_error("/connexion", "email_not_verified", email) return _redirect_with_error("/connexion", "email_not_verified", email)
login_user(User(row), remember=True) login_user(User(row), remember=True)
return redirect(next_url) return redirect(safe_next(next_url, fallback=_post_login_url(row["id"])))
@auth_bp.route("/logout", methods=["POST"]) @auth_bp.route("/logout", methods=["POST"])
@@ -259,7 +271,7 @@ def linkedin_login():
@auth_bp.route("/linkedin/callback", methods=["GET"]) @auth_bp.route("/linkedin/callback", methods=["GET"])
def linkedin_callback(): def linkedin_callback():
next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin") oauth_next = session.pop("oauth_next", None)
if request.args.get("error"): if request.args.get("error"):
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn. # L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
return _redirect_with_error("/connexion", "oauth_cancelled") return _redirect_with_error("/connexion", "oauth_cancelled")
@@ -290,4 +302,4 @@ def linkedin_callback():
"linkedin", subject, email, bool(userinfo.get("email_verified")) "linkedin", subject, email, bool(userinfo.get("email_verified"))
) )
login_user(user, remember=True) login_user(user, remember=True)
return redirect(next_url) return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id)))
+6 -6
View File
@@ -5,18 +5,18 @@ from flask_login import current_user
# Définition centralisée des sections de l'espace compte. # Définition centralisée des sections de l'espace compte.
# Ajouter une section future = ajouter une ligne ici (+ créer sa page). # Ajouter une section future = ajouter une ligne ici (+ créer sa page).
SECTIONS = [ SECTIONS = [
{
"key": "admin",
"label": "Compte",
"href": "/compte/admin",
"require_subscription": False,
},
{ {
"key": "abonnement", "key": "abonnement",
"label": "Abonnement", "label": "Abonnement",
"href": "/compte/abonnement", "href": "/compte/abonnement",
"require_subscription": False, "require_subscription": False,
}, },
{
"key": "admin",
"label": "Compte",
"href": "/compte/admin",
"require_subscription": False,
},
{ {
"key": "archives", "key": "archives",
"label": "Mes archives", "label": "Mes archives",
+70 -22
View File
@@ -1,5 +1,5 @@
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
from dash import dcc, html, register_page from dash import Input, Output, callback, dcc, html, register_page
from flask_login import current_user from flask_login import current_user
from src.pages._compte_shell import account_guard, account_shell from src.pages._compte_shell import account_guard, account_shell
@@ -16,10 +16,10 @@ register_page(
_PLAN_KEYS = ("simple", "soutien") _PLAN_KEYS = ("simple", "soutien")
def _csrf_input(index: str): def _csrf_input():
return dcc.Input( from flask_wtf.csrf import generate_csrf
type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token"
) return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
def _plan_card(meta: dict, trial: int | None, trial_used: bool): def _plan_card(meta: dict, trial: int | None, trial_used: bool):
@@ -37,24 +37,25 @@ def _plan_card(meta: dict, trial: int | None, trial_used: bool):
return dbc.Card( return dbc.Card(
dbc.CardBody( dbc.CardBody(
[ [
html.H4(meta["label"]), html.H4(meta["label"], className="mb-1"),
html.P(f"{meta['prix_ht']} € HT / mois"), html.P(f"{meta['prix_ht']} € HT / mois", className="text-muted mb-3"),
html.P(meta["description"]), html.P(meta["description"], className="mb-3"),
badge, badge,
html.Form( html.Form(
method="POST", method="POST",
action="/subscriptions/subscribe", action="/subscriptions/subscribe",
children=[ children=[
_csrf_input(f"subscribe-{meta['key']}"), _csrf_input(),
dcc.Input(type="hidden", name="plan", value=meta["key"]), dcc.Input(type="hidden", name="plan", value=meta["key"]),
html.Button( html.Button(
"S'abonner", type="submit", className="btn btn-primary" "S'abonner", type="submit", className="btn btn-primary"
), ),
], ],
), ),
] ],
className="p-4",
), ),
className="mb-3", className="h-100",
) )
@@ -64,22 +65,45 @@ def _plan_cards(trial_used=False, trial_for=plans.trial_days):
meta = plans.plan_meta(key) meta = plans.plan_meta(key)
if meta: if meta:
cards.append(_plan_card(meta, trial_for(key), trial_used)) cards.append(_plan_card(meta, trial_for(key), trial_used))
return dbc.Row([dbc.Col(c, md=6) for c in cards]) return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-5")
def _explainer(): def _explainer():
return html.Div( col_left = dbc.Col(
[ [
html.H4("À quoi servent les abonnements"), html.H4("Ce que les abonnements financent"),
html.Ul( html.Ul(
[ [
html.Li("Abonnement Frisbii (solution de paiement) : 50 €"), html.Li("Abonnement Frisbii (solution de paiement) : 50 €"),
html.Li("Serveur Scaleway : 40 €"), html.Li("Serveur Scaleway : 40 €"),
html.Li("Espace de coworking : 250 €"), html.Li("Espace de coworking : 250 €"),
html.Li("Salaire médian : 3 840 €"), html.Li(
[
"Salaire médian (",
html.Span(
"coût employeur",
id="salaire-modal-trigger",
style={
"cursor": "pointer",
"textDecoration": "underline",
"color": "var(--bs-link-color)",
},
),
") : 3 840 €",
]
),
] ]
), ),
html.H4("Ce que les abonnements de soutien permettraient"), ],
md=6,
style={
"borderRight": "1px solid var(--bs-border-color)",
"paddingRight": "2rem",
},
)
col_right = dbc.Col(
[
html.H4("Ce que les abonnements de soutien permettent"),
html.Ul( html.Ul(
[ [
html.Li( html.Li(
@@ -88,14 +112,17 @@ def _explainer():
"cette non-publication." "cette non-publication."
), ),
html.Li( html.Li(
"Coordination des bonnes volontés souhaitant militer pour une " "Fédération des bonnes volontés souhaitant militer pour une "
"législation plus exigeante sur la transparence de la commande " "législation plus ambitieuse sur la transparence de la commande "
"publique." "publique."
), ),
] ]
), ),
] ],
md=6,
style={"paddingLeft": "2rem"},
) )
return dbc.Row([col_left, col_right], className="align-items-start pt-2")
def _active_view(row): def _active_view(row):
@@ -120,7 +147,7 @@ def _active_view(row):
method="POST", method="POST",
action="/subscriptions/cancel", action="/subscriptions/cancel",
children=[ children=[
_csrf_input("cancel"), _csrf_input(),
html.Button( html.Button(
"Résilier", type="submit", className="btn btn-outline-danger" "Résilier", type="submit", className="btn btn-outline-danger"
), ),
@@ -150,6 +177,26 @@ def _feedback(query):
return out return out
_salaire_modal = dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Salaire médian — coût employeur")),
dbc.ModalBody(html.Img(src="/assets/salaire.png", style={"width": "100%"})),
],
id="salaire-modal",
size="lg",
is_open=False,
)
@callback(
Output("salaire-modal", "is_open"),
Input("salaire-modal-trigger", "n_clicks"),
prevent_initial_call=True,
)
def _open_salaire_modal(_):
return True
def layout(**query): def layout(**query):
guard = account_guard("/compte/abonnement", require_subscription=False) guard = account_guard("/compte/abonnement", require_subscription=False)
if guard is not None: if guard is not None:
@@ -166,10 +213,11 @@ def layout(**query):
db.has_used_trial(current_user.id) if current_user.is_authenticated else False db.has_used_trial(current_user.id) if current_user.is_authenticated else False
) )
body = [html.H2("Abonnement"), *_feedback(query)] body = [html.H2("Abonnement", className="mb-4"), *_feedback(query)]
if has_access and row is not None: if has_access and row is not None:
body.append(_active_view(row)) body.append(_active_view(row))
else: else:
body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()]) body.extend([_plan_cards(trial_used=trial_used), _explainer()])
body.append(_salaire_modal)
return account_shell("abonnement", html.Div(body)) return account_shell("abonnement", html.Div(body))
+10 -13
View File
@@ -5,7 +5,7 @@ import httpx
# NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la # NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la
# première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur # première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur
# l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin. # l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin.
_DEFAULT_BASE_URL = "https://api.reepay.com" _DEFAULT_BASE_URL = "https://api.frisbii.com"
_TIMEOUT = 15.0 _TIMEOUT = 15.0
@@ -53,19 +53,16 @@ def create_subscription_session(
cancel_url: str, cancel_url: str,
no_trial: bool = False, no_trial: bool = False,
) -> str: ) -> str:
prepare = {"plan": plan_handle, "customer": customer_handle} body: dict = {
"plan": plan_handle,
"customer": customer_handle,
"signup_method": "link",
"generate_handle": True,
}
if no_trial: if no_trial:
prepare["no_trial"] = True body["no_trial"] = True
data = _call( data = _call("POST", "/v1/subscription", json=body)
"POST", return data["hosted_page_links"]["payment_info"]
"/v1/session/subscription",
json={
"accept_url": accept_url,
"cancel_url": cancel_url,
"prepare_subscription": prepare,
},
)
return data["url"]
def cancel_subscription(subscription_handle: str) -> dict: def cancel_subscription(subscription_handle: str) -> dict:
+5 -2
View File
@@ -17,11 +17,11 @@ PLANS = {
"env": "FRISBII_PLAN_SIMPLE", "env": "FRISBII_PLAN_SIMPLE",
"label": "Abonnement simple", "label": "Abonnement simple",
"prix_ht": 20, "prix_ht": 20,
"description": "Accès aux fonctionnalités premium de decp.info.", "description": "Accès à des fonctionnalités supplémentaires de decp.info.",
}, },
"soutien": { "soutien": {
"env": "FRISBII_PLAN_SOUTIEN", "env": "FRISBII_PLAN_SOUTIEN",
"label": "Abonnement de soutien", "label": "Abonnement de soutien",
"prix_ht": 50, "prix_ht": 50,
"description": "Mêmes fonctionnalités, contribution renforcée au projet.", "description": "Mêmes fonctionnalités, contribution renforcée au projet.",
}, },
@@ -74,6 +74,9 @@ def trial_days(key: str) -> int | None:
except client.FrisbiiError: except client.FrisbiiError:
logger.warning("Impossible de lire le plan Frisbii %s", handle) logger.warning("Impossible de lire le plan Frisbii %s", handle)
return cached[1] if cached else None return cached[1] if cached else None
# L'API Frisbii/Reepay renvoie une liste de versions ; on prend la dernière (la plus récente).
if isinstance(plan, list):
plan = plan[-1] if plan else {}
days = _parse_trial_interval(plan.get("trial_interval")) days = _parse_trial_interval(plan.get("trial_interval"))
_trial_cache[handle] = (now, days) _trial_cache[handle] = (now, days)
return days return days
+23 -10
View File
@@ -29,29 +29,42 @@ def test_get_or_create_customer_creates_on_404(fake_httpx):
def test_create_subscription_session_returns_url(fake_httpx): def test_create_subscription_session_returns_url(fake_httpx):
fake_httpx["queue"].append( fake_httpx["queue"].append(
fake_httpx["Response"](200, {"id": "cs_1", "url": "https://pay.test/cs_1"}) fake_httpx["Response"](
200,
{
"hosted_page_links": {
"payment_info": "https://checkout.reepay.com/#/sub-1"
}
},
)
) )
url = client.create_subscription_session( url = client.create_subscription_session(
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko" "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko"
) )
assert url == "https://pay.test/cs_1" assert url == "https://checkout.reepay.com/#/sub-1"
body = fake_httpx["calls"][0]["json"] body = fake_httpx["calls"][0]["json"]
assert body["prepare_subscription"] == { assert body["plan"] == "plan_simple"
"plan": "plan_simple", assert body["customer"] == "decpinfo-1"
"customer": "decpinfo-1", assert body["signup_method"] == "link"
} assert "prepare_subscription" not in body
assert body["accept_url"] == "https://app/ok" assert "accept_url" not in body
assert body["cancel_url"] == "https://app/ko"
def test_create_subscription_session_no_trial(fake_httpx): def test_create_subscription_session_no_trial(fake_httpx):
fake_httpx["queue"].append( fake_httpx["queue"].append(
fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"}) fake_httpx["Response"](
200,
{
"hosted_page_links": {
"payment_info": "https://checkout.reepay.com/#/sub-2"
}
},
)
) )
client.create_subscription_session( client.create_subscription_session(
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True
) )
assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True assert fake_httpx["calls"][0]["json"]["no_trial"] is True
def test_http_error_raises_frisbii_error(fake_httpx): def test_http_error_raises_frisbii_error(fake_httpx):
Binary file not shown.