fix(auth): B-2.2 review fixes — Tailwind path + WCAG + race + flash + tests

C-1: Add templates/register.html (and templates/auth/**) to tailwind.config.js
content array so utility classes used by the signup template don't get purged
on next build. Rebuilt static/css/marketing.css; verified text-brand-navy/90
and min-h-[calc(100vh-62px)] are now compiled.

I-1: Replace flash() calls for missing required consents with WTForms
field-level errors (form.consent_cgu.errors.append / form.consent_confidentialite
.errors.append). Errors render inline next to each consent checkbox via
{% if form.consent_cgu.errors %}<p role="alert">…</p>{% endif %}. Prevents
session-backed flash messages from leaking across unrelated navigations.

I-2: Wrap user creation + flush in IntegrityError retry loop (max 5 attempts);
import IntegrityError from sqlalchemy.exc. Absorbs the inherent race between
_generate_unique_username's lookup and the subsequent flush under concurrent
signups. Added docstring note to _generate_unique_username explaining the
wrapper.

I-3: Move db.create_all() inside the try/finally in
test_signup_route_csrf_enforced so WTF_CSRF_ENABLED is restored even if
table creation fails.

I-4: Pin test_signup_rejects_duplicate_email assertion to status_code == 200
(WTForms validate_email raises ValidationError → form fails validation →
fall-through to default 200 render_template).

I-5: Add id="password-help" to the password help paragraph and
aria-describedby="password-help" to the password input so screen readers
announce the password requirements when the field is focused.

I-6: Bump flash banner text colors from -700/-800 to -900 variants
(text-amber-900, text-blue-900, text-red-900, text-green-900) for safer
WCAG 2.2 AA contrast against the -50 backgrounds. Same bump applied to the
new consent and password inline error renders.
This commit is contained in:
Allison
2026-04-27 22:43:00 -04:00
parent d2fc1f03ed
commit 3b324ad0b9
5 changed files with 75 additions and 27 deletions

View File

@@ -15,6 +15,7 @@ from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, BooleanField, SelectField from wtforms import StringField, PasswordField, SubmitField, BooleanField, SelectField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.exc import IntegrityError
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
import markdown import markdown
@@ -202,6 +203,11 @@ def _generate_unique_username(email: str) -> str:
Append a numeric suffix on collision. User.username is required, unique, Append a numeric suffix on collision. User.username is required, unique,
and capped at 20 chars by the schema. and capped at 20 chars by the schema.
Note: this lookup-then-pick is inherently racy under concurrent signups
(two requests can both read no-collision and both pick `jane`, then one
flush wins and the other raises IntegrityError). The signup() view wraps
the call site in an IntegrityError retry loop to absorb that race.
""" """
local = re.sub(r'[^a-z0-9]', '', email.split('@', 1)[0].lower())[:20] or 'user' local = re.sub(r'[^a-z0-9]', '', email.split('@', 1)[0].lower())[:20] or 'user'
candidate = local candidate = local
@@ -240,18 +246,21 @@ def signup():
if form.validate_on_submit(): if form.validate_on_submit():
# Hard-stop: required Loi 25 consents (CGU + politique de confidentialité). # Hard-stop: required Loi 25 consents (CGU + politique de confidentialité).
missing_required = [] # Errors are attached to the field directly (not flashed) so that the
# message stays scoped to this form-render and never leaks to other
# navigations via the session-backed flash queue.
consent_errors_present = False
if not form.consent_cgu.data: if not form.consent_cgu.data:
missing_required.append( form.consent_cgu.errors.append(
"Vous devez accepter les conditions d'utilisation pour créer un compte." "Vous devez accepter les conditions d'utilisation pour créer un compte."
) )
consent_errors_present = True
if not form.consent_confidentialite.data: if not form.consent_confidentialite.data:
missing_required.append( form.consent_confidentialite.errors.append(
"Vous devez accepter la politique de confidentialité pour créer un compte." "Vous devez accepter la politique de confidentialité pour créer un compte."
) )
if missing_required: consent_errors_present = True
for msg in missing_required: if consent_errors_present:
flash(msg, 'danger')
return render_template( return render_template(
'register.html', title='Créer un compte', form=form 'register.html', title='Créer un compte', form=form
), 400 ), 400
@@ -270,6 +279,12 @@ def signup():
form.password.data form.password.data
).decode('utf-8') ).decode('utf-8')
# Username generation can race under concurrent signups (two requests
# can both pick `jane` before either flushes). Retry up to 5 times on
# unique-constraint conflict; cap iterations to prevent an infinite
# loop on pathological username churn.
max_username_attempts = 5
for attempt in range(max_username_attempts):
user = User( user = User(
username=_generate_unique_username(email_normalized), username=_generate_unique_username(email_normalized),
email=email_normalized, email=email_normalized,
@@ -280,7 +295,13 @@ def signup():
email_verified=not is_email_verification_enabled(), email_verified=not is_email_verification_enabled(),
) )
db.session.add(user) db.session.add(user)
try:
db.session.flush() # need user.id for ConsentLog FK db.session.flush() # need user.id for ConsentLog FK
break
except IntegrityError:
db.session.rollback()
if attempt == max_username_attempts - 1:
raise
ip = _client_ip() ip = _client_ip()
ua = (request.headers.get('User-Agent') or '')[:500] ua = (request.headers.get('User-Agent') or '')[:500]

View File

@@ -828,6 +828,9 @@
.min-h-\[8rem\] { .min-h-\[8rem\] {
min-height: 8rem; min-height: 8rem;
} }
.min-h-\[calc\(100vh-62px\)\] {
min-height: calc(100vh - 62px);
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -2436,6 +2439,9 @@
.text-amber-800 { .text-amber-800 {
color: var(--color-amber-800); color: var(--color-amber-800);
} }
.text-amber-900 {
color: var(--color-amber-900);
}
.text-blue-400 { .text-blue-400 {
color: var(--color-blue-400); color: var(--color-blue-400);
} }
@@ -2451,6 +2457,9 @@
.text-blue-800 { .text-blue-800 {
color: var(--color-blue-800); color: var(--color-blue-800);
} }
.text-blue-900 {
color: var(--color-blue-900);
}
.text-brand-b3 { .text-brand-b3 {
color: #00c896; color: #00c896;
} }
@@ -2466,6 +2475,9 @@
.text-brand-navy\/80 { .text-brand-navy\/80 {
color: color-mix(in oklab, #060d1a 80%, transparent); color: color-mix(in oklab, #060d1a 80%, transparent);
} }
.text-brand-navy\/90 {
color: color-mix(in oklab, #060d1a 90%, transparent);
}
.text-emerald-500 { .text-emerald-500 {
color: var(--color-emerald-500); color: var(--color-emerald-500);
} }
@@ -2496,6 +2508,9 @@
.text-green-800 { .text-green-800 {
color: var(--color-green-800); color: var(--color-green-800);
} }
.text-green-900 {
color: var(--color-green-900);
}
.text-indigo-600 { .text-indigo-600 {
color: var(--color-indigo-600); color: var(--color-indigo-600);
} }
@@ -2529,6 +2544,9 @@
.text-red-800 { .text-red-800 {
color: var(--color-red-800); color: var(--color-red-800);
} }
.text-red-900 {
color: var(--color-red-900);
}
.text-teal-600 { .text-teal-600 {
color: var(--color-teal-600); color: var(--color-teal-600);
} }

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'], content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './templates/auth/**/*.html', './templates/register.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'],
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {

View File

@@ -13,10 +13,10 @@
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div role="alert" class="mb-3 p-3 rounded-lg text-sm <div role="alert" class="mb-3 p-3 rounded-lg text-sm
{% if category == 'danger' %}bg-red-50 text-red-700 border border-red-200 {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-800 border border-amber-200 {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-700 border border-green-200 {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-700 border border-blue-200{% endif %}"> {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }} {{ message }}
</div> </div>
{% endfor %} {% endfor %}
@@ -34,9 +34,9 @@
<div> <div>
<label for="password" class="block text-sm font-medium text-brand-navy mb-1">Mot de passe <span class="text-red-600" aria-hidden="true">*</span></label> <label for="password" class="block text-sm font-medium text-brand-navy mb-1">Mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
{{ form.password(id='password', autocomplete='new-password', required=true, minlength=8, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} {{ form.password(id='password', autocomplete='new-password', required=true, minlength=8, **{'aria-required':'true', 'aria-describedby':'password-help', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.password.errors %}<p class="text-xs text-red-700 mt-1">{{ form.password.errors[0] }}</p>{% endif %} {% if form.password.errors %}<p class="text-xs text-red-900 mt-1">{{ form.password.errors[0] }}</p>{% endif %}
<p class="text-xs text-brand-navy/70 mt-1">8&nbsp;caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.</p> <p id="password-help" class="text-xs text-brand-navy/70 mt-1">8&nbsp;caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.</p>
</div> </div>
<div> <div>
@@ -77,11 +77,13 @@
{{ form.consent_cgu(id='consent_cgu', required=true, **{'aria-required':'true', 'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} {{ form.consent_cgu(id='consent_cgu', required=true, **{'aria-required':'true', 'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte les <a href="/legal/conditions" target="_blank" rel="noopener" class="grad-text underline">conditions d'utilisation</a>. <span class="text-red-600" aria-hidden="true">*</span></span> <span>J'accepte les <a href="/legal/conditions" target="_blank" rel="noopener" class="grad-text underline">conditions d'utilisation</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
</label> </label>
{% if form.consent_cgu.errors %}<p class="text-xs text-red-900 mt-1" role="alert">{{ form.consent_cgu.errors[0] }}</p>{% endif %}
<label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90"> <label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.consent_confidentialite(id='consent_confidentialite', required=true, **{'aria-required':'true', 'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} {{ form.consent_confidentialite(id='consent_confidentialite', required=true, **{'aria-required':'true', 'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte la <a href="/legal/confidentialite" target="_blank" rel="noopener" class="grad-text underline">politique de confidentialité</a>. <span class="text-red-600" aria-hidden="true">*</span></span> <span>J'accepte la <a href="/legal/confidentialite" target="_blank" rel="noopener" class="grad-text underline">politique de confidentialité</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
</label> </label>
{% if form.consent_confidentialite.errors %}<p class="text-xs text-red-900 mt-1" role="alert">{{ form.consent_confidentialite.errors[0] }}</p>{% endif %}
<label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90"> <label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.consent_marketing(id='consent_marketing', **{'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} {{ form.consent_marketing(id='consent_marketing', **{'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}

View File

@@ -209,7 +209,12 @@ def test_signup_rejects_duplicate_email():
data['consent_confidentialite'] = 'y' data['consent_confidentialite'] = 'y'
resp = client.post('/signup', data=data) resp = client.post('/signup', data=data)
# Form re-rendered (200) with validation error from validate_email. # Form re-rendered (200) with validation error from validate_email.
assert resp.status_code in (200, 400) # Pinned to 200 exactly: validate_email raises ValidationError →
# form fails validate_on_submit() → fall-through to the final
# `return render_template(...)` which uses the default 200.
assert resp.status_code == 200, (
f"Form should re-render with error inline (200), got {resp.status_code}"
)
body = resp.data.decode('utf-8').lower() body = resp.data.decode('utf-8').lower()
assert 'courriel' in body and ('déjà' in body or 'deja' in body) assert 'courriel' in body and ('déjà' in body or 'deja' in body)
@@ -285,8 +290,10 @@ def test_signup_route_csrf_enforced():
with app.app_context(): with app.app_context():
prev = app.config.get('WTF_CSRF_ENABLED', True) prev = app.config.get('WTF_CSRF_ENABLED', True)
app.config['WTF_CSRF_ENABLED'] = True app.config['WTF_CSRF_ENABLED'] = True
db.create_all()
try: try:
# create_all() is INSIDE the try so a failure here still restores
# WTF_CSRF_ENABLED for the rest of the test session.
db.create_all()
client = app.test_client() client = app.test_client()
data = dict(VALID_FORM) data = dict(VALID_FORM)
data['email'] = 'csrf@example.qc.ca' data['email'] = 'csrf@example.qc.ca'