Refs #73 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
34 KiB
Espace « Mon compte » — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Transformer /compte en un espace multi-sections avec navigation latérale, contrôle d'accès à 3 niveaux, et implémenter intégralement la section Compte (changement d'email avec re-vérification, changement de mot de passe, suppression de compte).
Architecture: Une page Dash register_page par section sous /compte/*, enveloppée par une coquille commune account_shell (sidebar desktop + dbc.Offcanvas mobile). Les nouvelles actions de compte passent par des routes Flask /auth/* (formulaires HTML POST), comme l'existant. Le changement d'email stocke une adresse en attente confirmée par lien email.
Tech Stack: Dash 3.4, Dash Bootstrap Components, Flask Blueprint, SQLite (users.sqlite), flask-login, flask-wtf (CSRF), Brevo (emails).
Global Constraints
- Importer les modules avec le préfixe
src.(src.auth.db,src.pages._compte_shell, …). - UI en français.
- Les formulaires POSTent vers
/auth/*; chaque<form>contient un input caché CSRFdcc.Input(type="hidden", id={"type": "csrf-input", "index": "<unique>"}, name="csrf_token")rempli par le callback global desrc/app.py. Chaqueindexdoit être unique dans la page. MIN_PASSWORD_LENGTH = 8(déjà défini danssrc/auth/routes.py).- Composants Dash Bootstrap Components autant que possible.
- Lancer les tests avec
uv run pytest(ne passource .venv/bin/activate). - Routes auth protégées par
@login_required.
Task 1: Couche DB — email en attente + migration
Files:
- Modify:
src/auth/db.py(schémaUSERS_SCHEMA,init_schema, nouvelles fonctions) - Test:
tests/auth/test_db.py
Interfaces:
-
Consumes:
get_conn(),_now(),init_schema()existants. -
Produces:
set_pending_email(user_id: int, email: str) -> Nonepromote_pending_email(user_id: int) -> str | None(promeutpending_emailenemail, metemail_verified=1, effacepending_email, renvoie le nouvel email ouNonesi aucun en attente)- colonne
users.pending_email TEXT(nullable), ajoutée par migration idempotente.
-
Step 1: Write the failing test
Ajouter à la fin de tests/auth/test_db.py :
def test_pending_email_column_exists(users_db_path):
from src.auth import db
db.init_schema()
cols = {r["name"] for r in db.get_conn().execute("PRAGMA table_info(users)")}
assert "pending_email" in cols
def test_set_and_promote_pending_email(users_db_path):
from werkzeug.security import generate_password_hash
from src.auth import db
db.init_schema()
uid = db.create_user("old@example.fr", generate_password_hash("password12"))
db.set_pending_email(uid, "new@example.fr")
assert db.get_user_by_id(uid)["pending_email"] == "new@example.fr"
promoted = db.promote_pending_email(uid)
assert promoted == "new@example.fr"
row = db.get_user_by_id(uid)
assert row["email"] == "new@example.fr"
assert row["pending_email"] is None
assert row["email_verified"] == 1
def test_promote_pending_email_noop_when_empty(users_db_path):
from werkzeug.security import generate_password_hash
from src.auth import db
db.init_schema()
uid = db.create_user("a@b.c", generate_password_hash("password12"))
assert db.promote_pending_email(uid) is None
assert db.get_user_by_id(uid)["email"] == "a@b.c"
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/auth/test_db.py::test_pending_email_column_exists tests/auth/test_db.py::test_set_and_promote_pending_email tests/auth/test_db.py::test_promote_pending_email_noop_when_empty -v
Expected: FAIL (pending_email colonne absente / AttributeError: module 'src.auth.db' has no attribute 'set_pending_email').
- Step 3: Add the column to the schema
Dans src/auth/db.py, ajouter la colonne pending_email à la table users du USERS_SCHEMA (après la ligne email_verified ...) :
email_verified INTEGER NOT NULL DEFAULT 0,
pending_email TEXT,
created_at TEXT NOT NULL,
- Step 4: Add an idempotent migration for existing databases
CREATE TABLE IF NOT EXISTS ne modifie pas une table déjà créée : ajouter une migration. Remplacer la fonction init_schema par :
def init_schema() -> None:
conn = get_conn()
conn.executescript(USERS_SCHEMA)
_migrate(conn)
def _migrate(conn: sqlite3.Connection) -> None:
cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
if "pending_email" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN pending_email TEXT")
- Step 5: Add the data-access functions
Ajouter dans src/auth/db.py après update_password_hash :
def set_pending_email(user_id: int, email: str) -> None:
get_conn().execute(
"UPDATE users SET pending_email = ?, updated_at = ? WHERE id = ?",
(email.lower(), _now(), user_id),
)
def promote_pending_email(user_id: int) -> str | None:
conn = get_conn()
row = conn.execute(
"SELECT pending_email FROM users WHERE id = ?", (user_id,)
).fetchone()
if row is None or not row["pending_email"]:
return None
new_email = row["pending_email"]
conn.execute(
"UPDATE users SET email = ?, pending_email = NULL, "
"email_verified = 1, updated_at = ? WHERE id = ?",
(new_email, _now(), user_id),
)
return new_email
- Step 6: Run tests to verify they pass
Run: uv run pytest tests/auth/test_db.py -v
Expected: PASS (tous les tests du module).
- Step 7: Commit
git add src/auth/db.py tests/auth/test_db.py
git commit -m "feat(auth): email en attente (pending_email) + migration (#73)"
Task 2: Mailer — email de confirmation de changement d'adresse
Files:
- Modify:
src/auth/mailer.py - Test:
tests/auth/test_mailer.py
Interfaces:
-
Consumes:
_base_url(),_template_id(),_send_template()existants ; fixturemail_outbox. -
Produces:
send_email_change_email(email: str, token: str) -> None(lien vers/auth/confirm-email-change?token=..., réutilise le templateBREVO_TEMPLATE_VERIFY_ID). -
Step 1: Write the failing test
Ajouter à tests/auth/test_mailer.py :
def test_send_email_change_email(mail_outbox, monkeypatch):
from src.auth import mailer
monkeypatch.setenv("APP_BASE_URL", "https://decp.info")
mailer.send_email_change_email("new@example.fr", "tok123")
assert len(mail_outbox) == 1
assert mail_outbox[0].recipients == ["new@example.fr"]
- Step 2: Run test to verify it fails
Run: uv run pytest tests/auth/test_mailer.py::test_send_email_change_email -v
Expected: FAIL (AttributeError: module 'src.auth.mailer' has no attribute 'send_email_change_email').
- Step 3: Implement the mailer function
Ajouter à la fin de src/auth/mailer.py :
def send_email_change_email(email: str, token: str) -> None:
link = f"{_base_url()}/auth/confirm-email-change?token={token}"
_send_template(_template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link})
- Step 4: Run test to verify it passes
Run: uv run pytest tests/auth/test_mailer.py::test_send_email_change_email -v
Expected: PASS
- Step 5: Commit
git add src/auth/mailer.py tests/auth/test_mailer.py
git commit -m "feat(auth): email de confirmation de changement d'adresse (#73)"
Task 3: Routes — changement d'email avec re-vérification
Files:
- Modify:
src/auth/routes.py - Test:
tests/auth/test_email_change.py(créer)
Interfaces:
-
Consumes:
db.get_user_by_email,db.set_pending_email,db.promote_pending_email,tokens.create_verification_token,tokens.consume_verification_token,mailer.send_email_change_email,validate_email,_redirect_with_error. -
Produces : routes Flask
POST /auth/change-emailetGET /auth/confirm-email-change. Redirections :/compte/admin?email_pending=1,/compte/admin?email_changed=1,/compte/admin?error=<invalid_email|email_taken|email_send_failed>. -
Step 1: Write the failing tests
Créer tests/auth/test_email_change.py :
from werkzeug.security import generate_password_hash
from src.auth import db
def _login(client, email="old@example.fr", password="password12"):
db.init_schema()
uid = db.create_user(email, generate_password_hash(password))
db.set_email_verified(uid)
client.post("/auth/login", data={"email": email, "password": password})
return uid
def test_change_email_requires_login(client, users_db_path):
resp = client.post("/auth/change-email", data={"email": "x@y.z"})
assert resp.status_code in (302, 401)
def test_change_email_sets_pending_and_sends_mail(client, users_db_path, mail_outbox):
uid = _login(client)
resp = client.post("/auth/change-email", data={"email": "new@example.fr"})
assert resp.status_code == 302
assert "email_pending=1" in resp.headers["Location"]
assert db.get_user_by_id(uid)["pending_email"] == "new@example.fr"
assert db.get_user_by_id(uid)["email"] == "old@example.fr" # pas encore changé
assert mail_outbox[0].recipients == ["new@example.fr"]
def test_change_email_invalid(client, users_db_path, mail_outbox):
_login(client)
resp = client.post("/auth/change-email", data={"email": "pas-un-email"})
assert "error=invalid_email" in resp.headers["Location"]
assert mail_outbox == []
def test_change_email_already_taken(client, users_db_path, mail_outbox):
_login(client)
db.create_user("taken@example.fr", generate_password_hash("password12"))
resp = client.post("/auth/change-email", data={"email": "taken@example.fr"})
assert "error=email_taken" in resp.headers["Location"]
assert mail_outbox == []
def test_confirm_email_change_promotes(client, users_db_path, mail_outbox):
from src.auth import tokens
uid = _login(client)
client.post("/auth/change-email", data={"email": "new@example.fr"})
token = tokens.create_verification_token(uid)
resp = client.get(f"/auth/confirm-email-change?token={token}")
assert resp.status_code == 302
assert "email_changed=1" in resp.headers["Location"]
assert db.get_user_by_id(uid)["email"] == "new@example.fr"
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/auth/test_email_change.py -v
Expected: FAIL (404 sur /auth/change-email).
- Step 3: Implement the change-email route
Ajouter à la fin de src/auth/routes.py (les imports validate_email, EmailNotValidError, current_user, login_required, mailer, tokens sont déjà présents en tête de fichier) :
@auth_bp.route("/change-email", methods=["POST"])
@login_required
def change_email():
email = (request.form.get("email") or "").strip()
try:
valid = validate_email(email, check_deliverability=False)
email = valid.normalized.lower()
except EmailNotValidError:
return _redirect_with_error("/compte/admin", "invalid_email")
if db.get_user_by_email(email) is not None:
return _redirect_with_error("/compte/admin", "email_taken")
db.set_pending_email(current_user.id, email)
token = tokens.create_verification_token(current_user.id)
try:
mailer.send_email_change_email(email, token)
except Exception:
logger.exception("Échec d'envoi de l'email de changement d'adresse")
return _redirect_with_error("/compte/admin", "email_send_failed")
return redirect("/compte/admin?email_pending=1")
- Step 4: Implement the confirm-email-change route
Ajouter à la suite dans src/auth/routes.py :
@auth_bp.route("/confirm-email-change", methods=["GET"])
def confirm_email_change():
token = request.args.get("token") or ""
user_id = tokens.consume_verification_token(token)
if user_id is None:
return redirect("/compte/admin?error=invalid_token")
db.promote_pending_email(user_id)
return redirect("/compte/admin?email_changed=1")
- Step 5: Run tests to verify they pass
Run: uv run pytest tests/auth/test_email_change.py -v
Expected: PASS
- Step 6: Commit
git add src/auth/routes.py tests/auth/test_email_change.py
git commit -m "feat(auth): routes change-email + confirm-email-change (#73)"
Task 4: Route — suppression de compte
Files:
- Modify:
src/auth/routes.py - Test:
tests/auth/test_account.py
Interfaces:
-
Consumes:
db.get_user_by_id,db.delete_user,db.delete_email_verification_tokens_for_user,db.delete_password_reset_tokens_for_user,check_password_hash,logout_user. -
Produces: route Flask
POST /auth/delete-account. Succès →redirect("/?account_deleted=1"); mauvais mot de passe →/compte/admin?error=invalid_current_password. -
Step 1: Write the failing tests
Ajouter à tests/auth/test_account.py :
def test_delete_account_requires_login(client, users_db_path):
resp = client.post("/auth/delete-account", data={"current_password": "x"})
assert resp.status_code in (302, 401)
def test_delete_account_wrong_password(client, users_db_path):
uid = _login(client)
resp = client.post("/auth/delete-account", data={"current_password": "wrong"})
assert "error=invalid_current_password" in resp.headers["Location"]
assert db.get_user_by_id(uid) is not None
def test_delete_account_success(client, users_db_path):
uid = _login(client)
resp = client.post(
"/auth/delete-account", data={"current_password": "old-password12"}
)
assert resp.status_code == 302
assert "account_deleted=1" in resp.headers["Location"]
assert db.get_user_by_id(uid) is None
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/auth/test_account.py -k delete -v
Expected: FAIL (404 sur /auth/delete-account).
- Step 3: Implement the delete-account route
Ajouter à la fin de src/auth/routes.py :
@auth_bp.route("/delete-account", methods=["POST"])
@login_required
def delete_account():
current_pw = request.form.get("current_password") or ""
row = db.get_user_by_id(current_user.id)
if not check_password_hash(row["password_hash"], current_pw):
return _redirect_with_error("/compte/admin", "invalid_current_password")
user_id = current_user.id
db.delete_email_verification_tokens_for_user(user_id)
db.delete_password_reset_tokens_for_user(user_id)
db.delete_user(user_id)
logout_user()
return redirect("/?account_deleted=1")
- Step 4: Run tests to verify they pass
Run: uv run pytest tests/auth/test_account.py -v
Expected: PASS
- Step 5: Commit
git add src/auth/routes.py tests/auth/test_account.py
git commit -m "feat(auth): route delete-account avec confirmation par mot de passe (#73)"
Task 5: Coquille partagée _compte_shell.py
Files:
- Create:
src/pages/_compte_shell.py - Test:
tests/test_compte_shell.py(créer)
Interfaces:
-
Consumes:
dash.dcc,dash_bootstrap_components,flask_login.current_user. -
Produces:
SECTIONS: list[dict]— chaque entrée{"key", "label", "href", "require_subscription"}.current_user_has_subscription() -> bool(stubFalse).visible_sections(has_subscription: bool) -> list[dict].guard_redirect(is_authenticated: bool, has_subscription: bool, require_subscription: bool, path: str) -> str | None.account_guard(path: str, require_subscription: bool)→dcc.Location | None.account_shell(active: str, contenu)→ composantdbc(sidebar + offcanvas + contenu).
-
Step 1: Write the failing tests (fonctions pures)
Créer tests/test_compte_shell.py :
from src.pages import _compte_shell as shell
def test_visible_sections_hides_gated_without_subscription():
keys = {s["key"] for s in shell.visible_sections(has_subscription=False)}
assert "admin" in keys
assert "abonnement" in keys
assert "archives" not in keys
def test_visible_sections_shows_all_with_subscription():
keys = {s["key"] for s in shell.visible_sections(has_subscription=True)}
assert "archives" in keys
def test_guard_redirect_anonymous_goes_to_login():
href = shell.guard_redirect(
is_authenticated=False,
has_subscription=False,
require_subscription=False,
path="/compte/admin",
)
assert href == "/connexion?next=/compte/admin"
def test_guard_redirect_unsubscribed_on_gated_goes_to_abonnement():
href = shell.guard_redirect(
is_authenticated=True,
has_subscription=False,
require_subscription=True,
path="/compte/archives",
)
assert href == "/compte/abonnement"
def test_guard_redirect_allowed_returns_none():
href = shell.guard_redirect(
is_authenticated=True,
has_subscription=False,
require_subscription=False,
path="/compte/admin",
)
assert href is None
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/test_compte_shell.py -v
Expected: FAIL (ModuleNotFoundError / attributs manquants).
- Step 3: Implement the shell module
Créer src/pages/_compte_shell.py :
import dash_bootstrap_components as dbc
from dash import dcc, html
from flask_login import current_user
# Définition centralisée des sections de l'espace compte.
# Ajouter une section future = ajouter une ligne ici (+ créer sa page).
SECTIONS = [
{"key": "admin", "label": "Compte", "href": "/compte/admin",
"require_subscription": False},
{"key": "abonnement", "label": "Abonnement", "href": "/compte/abonnement",
"require_subscription": False},
{"key": "archives", "label": "Mes archives", "href": "/compte/archives",
"require_subscription": True},
{"key": "filtres", "label": "Mes filtres", "href": "/compte/filtres",
"require_subscription": True},
{"key": "siret", "label": "Mon SIRET", "href": "/compte/siret",
"require_subscription": True},
]
def current_user_has_subscription() -> bool:
"""Stub : à brancher sur la facturation (issue #73)."""
return False
def visible_sections(has_subscription: bool) -> list[dict]:
return [
s for s in SECTIONS if has_subscription or not s["require_subscription"]
]
def guard_redirect(
is_authenticated: bool,
has_subscription: bool,
require_subscription: bool,
path: str,
) -> str | None:
if not is_authenticated:
return f"/connexion?next={path}"
if require_subscription and not has_subscription:
return "/compte/abonnement"
return None
def account_guard(path: str, require_subscription: bool):
href = guard_redirect(
current_user.is_authenticated,
current_user_has_subscription(),
require_subscription,
path,
)
return dcc.Location(href=href, id="compte-guard-redirect") if href else None
def _nav(active: str):
links = [
dbc.NavLink(s["label"], href=s["href"], active=(s["key"] == active))
for s in visible_sections(current_user_has_subscription())
]
return dbc.Nav(links, vertical=True, pills=True)
def account_shell(active: str, contenu):
sidebar = dbc.Col(
html.Div([html.H5("Mon compte", className="mb-3"), _nav(active)]),
md=3,
className="d-none d-md-block",
)
mobile = html.Div(
[
dbc.Button(
"☰ Sections",
id="compte-offcanvas-open",
color="secondary",
outline=True,
className="mb-3",
),
dbc.Offcanvas(
_nav(active),
id="compte-offcanvas",
title="Mon compte",
is_open=False,
),
],
className="d-md-none",
)
content = dbc.Col([mobile, contenu], md=9)
return dbc.Container(dbc.Row([sidebar, content]), className="py-4")
- Step 4: Run tests to verify they pass
Run: uv run pytest tests/test_compte_shell.py -v
Expected: PASS
- Step 5: Commit
git add src/pages/_compte_shell.py tests/test_compte_shell.py
git commit -m "feat(compte): coquille account_shell (sidebar + offcanvas + garde d'accès) (#73)"
Task 6: Page /compte/admin + redirection /compte + navbar
Files:
- Create:
src/pages/compte_admin.py - Modify:
src/pages/compte.py(devient une redirection) - Modify:
src/app.py:269(lien navbar/compte→/compte/admin) - Modify:
src/auth/routes.py(cibles de redirection dechange_password:/compte→/compte/admin) - Test:
tests/auth/test_account.py(mise à jour de l'assertion existante)
Interfaces:
-
Consumes:
account_shell,account_guard(Task 5) ; routes/auth/change-email,/auth/change-password,/auth/delete-account,/auth/logout. -
Produces: page enregistrée
/compte/admin(name "Mon compte"), page/compteredirigeant vers/compte/admin. -
Step 1: Update the existing change-password redirect target
Dans src/auth/routes.py, fonction change_password, remplacer les 3 occurrences de "/compte" par "/compte/admin" :
-
_redirect_with_error("/compte", "invalid_current_password")→"/compte/admin" -
_redirect_with_error("/compte", "password_too_short")→"/compte/admin" -
_redirect_with_error("/compte", "password_mismatch")→"/compte/admin" -
redirect("/compte?password_changed=1")→redirect("/compte/admin?password_changed=1") -
Step 2: Update the existing test assertion
Dans tests/auth/test_account.py, test_change_password_success, remplacer :
assert "password_changed=1" in resp.headers["Location"]
reste valide (l'URL /compte/admin?password_changed=1 contient toujours password_changed=1). Aucune modification nécessaire — vérifier en lançant :
Run: uv run pytest tests/auth/test_account.py -k change_password -v
Expected: PASS
- Step 3: Create the
/compte/adminpage
Créer src/pages/compte_admin.py :
import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, dcc, html, register_page
from flask_login import current_user
from src.pages._compte_shell import account_guard, account_shell
register_page(
__name__,
path="/compte/admin",
title="Mon compte | decp.info",
name="Mon compte",
description="Gestion de votre compte decp.info.",
)
ERROR_MESSAGES = {
"invalid_current_password": "Le mot de passe actuel est incorrect.",
"password_too_short": "Le nouveau mot de passe doit faire au moins 8 caractères.",
"password_mismatch": "Les nouveaux mots de passe ne correspondent pas.",
"invalid_email": "L'adresse email n'est pas valide.",
"email_taken": "Cette adresse email est déjà utilisée.",
"email_send_failed": "L'envoi de l'email de confirmation a échoué.",
"invalid_token": "Le lien de confirmation est invalide ou expiré.",
}
SUCCESS_MESSAGES = {
"password_changed": "Mot de passe mis à jour.",
"email_pending": "Un email de confirmation a été envoyé à la nouvelle adresse.",
"email_changed": "Votre adresse email a été mise à jour.",
}
def _csrf(index: str):
return dcc.Input(
type="hidden",
id={"type": "csrf-input", "index": index},
name="csrf_token",
)
def _email_section():
return html.Div(
[
html.H4("Adresse email", className="mt-2"),
html.P([html.Strong("Email actuel : "), current_user.email]),
html.Form(
method="POST",
action="/auth/change-email",
children=[
_csrf("change-email"),
dbc.Label("Nouvelle adresse email"),
dbc.Input(type="email", name="email", required=True,
className="mb-3"),
dbc.Button("Mettre à jour l'email", type="submit",
color="primary"),
],
),
]
)
def _password_section():
return html.Div(
[
html.H4("Mot de passe", className="mt-4"),
html.Form(
method="POST",
action="/auth/change-password",
children=[
_csrf("change-password"),
dbc.Label("Mot de passe actuel"),
dbc.Input(type="password", name="current_password",
required=True, className="mb-3"),
dbc.Label("Nouveau mot de passe (8 caractères minimum)"),
dbc.Input(type="password", name="password", required=True,
minLength=8, className="mb-3"),
dbc.Label("Confirmer le nouveau mot de passe"),
dbc.Input(type="password", name="password_confirm",
required=True, minLength=8, className="mb-3"),
dbc.Button("Changer le mot de passe", type="submit",
color="primary"),
],
),
]
)
def _danger_section():
return html.Div(
[
html.H4("Zone danger", className="mt-5 text-danger"),
html.P("La suppression de votre compte est définitive."),
dbc.Button("Supprimer mon compte", id="delete-open",
color="danger", outline=True),
dbc.Modal(
id="delete-modal",
is_open=False,
children=[
dbc.ModalHeader(dbc.ModalTitle("Supprimer le compte")),
html.Form(
method="POST",
action="/auth/delete-account",
children=[
dbc.ModalBody(
[
_csrf("delete-account"),
html.P(
"Cette action est définitive. "
"Saisissez votre mot de passe pour confirmer."
),
dbc.Input(type="password",
name="current_password",
required=True,
placeholder="Mot de passe"),
]
),
dbc.ModalFooter(
[
dbc.Button("Annuler", id="delete-cancel",
color="secondary"),
dbc.Button("Supprimer définitivement",
type="submit", color="danger"),
]
),
],
),
],
),
],
className="border border-danger rounded p-3 mt-4",
)
def _logout_section():
return html.Form(
method="POST",
action="/auth/logout",
className="mt-4",
children=[
_csrf("logout"),
dbc.Button("Déconnexion", type="submit", color="secondary"),
],
)
def layout(error=None, password_changed=None, email_pending=None,
email_changed=None, **_):
guard = account_guard("/compte/admin", require_subscription=False)
if guard is not None:
return guard
alerts = []
if error in ERROR_MESSAGES:
alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger"))
for flag, value in (("password_changed", password_changed),
("email_pending", email_pending),
("email_changed", email_changed)):
if value == "1":
alerts.append(dbc.Alert(SUCCESS_MESSAGES[flag], color="success"))
contenu = html.Div(
[
html.H2("Compte"),
*alerts,
_email_section(),
html.Hr(className="mt-4"),
_password_section(),
html.Hr(className="mt-4"),
_danger_section(),
_logout_section(),
]
)
return account_shell("admin", contenu)
@callback(
Output("delete-modal", "is_open"),
Input("delete-open", "n_clicks"),
Input("delete-cancel", "n_clicks"),
State("delete-modal", "is_open"),
prevent_initial_call=True,
)
def _toggle_delete_modal(_open, _cancel, is_open):
return not is_open
@callback(
Output("compte-offcanvas", "is_open"),
Input("compte-offcanvas-open", "n_clicks"),
State("compte-offcanvas", "is_open"),
prevent_initial_call=True,
)
def _toggle_offcanvas(_n, is_open):
return not is_open
- Step 4: Convert
/compteinto a redirect
Remplacer tout le contenu de src/pages/compte.py par :
from dash import dcc, register_page
register_page(
__name__,
path="/compte",
title="Mon compte | decp.info",
name="Mon compte",
description="Redirection vers la gestion de compte.",
)
def layout(**_):
return dcc.Location(href="/compte/admin", id="compte-root-redirect")
- Step 5: Update the navbar link
Dans src/app.py:269, remplacer :
dbc.DropdownMenuItem("Mon compte", href="/compte"),
par :
dbc.DropdownMenuItem("Mon compte", href="/compte/admin"),
- Step 6: Verify the app imports and auth tests pass
Run: uv run python -c "import src.app"
Expected: aucune erreur (les pages s'enregistrent sans exception de callback dupliqué).
Run: uv run pytest tests/auth/test_account.py -v
Expected: PASS
- Step 7: Commit
git add src/pages/compte_admin.py src/pages/compte.py src/app.py src/auth/routes.py
git commit -m "feat(compte): page /compte/admin (email, mot de passe, suppression) (#73)"
Task 7: Coquille de la page /compte/abonnement
Files:
- Create:
src/pages/compte_abonnement.py
Interfaces:
-
Consumes:
account_shell,account_guard(Task 5). -
Produces: page enregistrée
/compte/abonnement(accessible à tout compte connecté). -
Step 1: Create the page
Créer src/pages/compte_abonnement.py :
from dash import html, register_page
from src.pages._compte_shell import account_guard, account_shell
register_page(
__name__,
path="/compte/abonnement",
title="Abonnement | decp.info",
name="Abonnement",
description="Gestion de votre abonnement decp.info.",
)
def layout(**_):
guard = account_guard("/compte/abonnement", require_subscription=False)
if guard is not None:
return guard
contenu = html.Div(
[
html.H2("Abonnement"),
html.P("La gestion de l'abonnement arrive bientôt."),
]
)
return account_shell("abonnement", contenu)
- Step 2: Verify the app imports
Run: uv run python -c "import src.app"
Expected: aucune erreur.
- Step 3: Commit
git add src/pages/compte_abonnement.py
git commit -m "feat(compte): coquille de la section Abonnement (#73)"
Task 8: Test d'intégration — navigation et accès (Selenium)
Files:
- Create:
tests/test_compte_pages.py
Interfaces:
-
Consumes:
dash.testing(dash_duo), pattern des tests Selenium existants danstests/test_main.py. -
Step 1: Inspect an existing Selenium test for the import pattern
Lire le haut de tests/test_main.py pour reprendre exactement la façon d'importer app et d'utiliser dash_duo.start_server(app) ainsi que la configuration des variables d'environnement (DEVELOPMENT, USERS_DB_PATH).
- Step 2: Write the integration test
Créer tests/test_compte_pages.py (adapter from src.app import app au nom réel utilisé dans tests/test_main.py) :
def test_compte_redirects_anonymous_to_login(dash_duo):
from src.app import app
dash_duo.start_server(app)
dash_duo.driver.get(dash_duo.server_url + "/compte/admin")
dash_duo.wait_for_text_to_equal("h1, h2", "Connexion", timeout=8)
assert "/connexion" in dash_duo.driver.current_url
def test_compte_root_redirects_to_admin(dash_duo):
from src.app import app
dash_duo.start_server(app)
dash_duo.driver.get(dash_duo.server_url + "/compte")
# /compte (anonyme) → /compte/admin → /connexion
dash_duo.wait_for_text_to_equal("h1, h2", "Connexion", timeout=8)
- Step 3: Run the test
Run: uv run pytest tests/test_compte_pages.py -v
Expected: PASS (un Chrome/Chromium doit être disponible).
Si le sélecteur de titre de la page Connexion diffère, ajuster
wait_for_text_to_equalau texte réel rendu parsrc/pages/connexion.py.
- Step 4: Run the full suite
Run: uv run pytest
Expected: PASS (aucune régression).
- Step 5: Commit
git add tests/test_compte_pages.py
git commit -m "test(compte): redirections d'accès de l'espace compte (#73)"
Self-Review
Spec coverage:
- Routage une page/section + redirection
/compte→ Tasks 6, 7 ✓ - Coquille
account_shell(sidebar + offcanvas) → Task 5 ✓ - 3 niveaux d'accès +
account_guard+ stubcurrent_user_has_subscription→ Task 5 ✓ - Sections gated masquées → Task 5 (
visible_sections) ✓ - Section Compte agencement A (email, mdp, zone danger + modale) → Task 6 ✓
- Changement d'email avec re-vérification (
pending_email, lien) → Tasks 1, 2, 3 ✓ - Suppression de compte (vérif mdp, purge tokens, logout) → Task 4 ✓
- Tests routes + accès → Tasks 1-4, 8 ✓
Écarts assumés vs spec :
- Le spec mentionnait
update_email; le flux de re-vérification le remplace parset_pending_email+promote_pending_email(plus sûr, pas de fonction inutilisée — YAGNI). - L'email de changement réutilise le template Brevo
BREVO_TEMPLATE_VERIFY_ID(pas de nouvelle variable d'environnement à déployer).
Placeholder scan : aucun TBD/TODO ; tout le code est fourni.
Type consistency : set_pending_email/promote_pending_email/visible_sections/guard_redirect/account_guard/account_shell ont des signatures cohérentes entre la définition (Tasks 1, 5) et leurs usages (Tasks 3, 6, 7).
Hors périmètre (rappel)
- Contenu réel de la section Abonnement (perks, tarifs, paiement).
- Pages Archives, Filtres, Mon SIRET (leurs entrées existent dans
SECTIONSmais restent masquées tant quecurrent_user_has_subscription()renvoieFalse). - Branchement réel de la facturation.