Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bb59956
feat: Add Auth0 SSO authentication with account linking
o-rayer Nov 13, 2025
03b69ec
Personnalisation des niveaux de pratique (#839)
gdaviet Nov 2, 2025
c9eaa0e
Revert caching of activity_type/event_type lists due to ORM issues (#…
gdaviet Nov 2, 2025
ef3b659
More flexible CSV date import (#841)
gdaviet Nov 9, 2025
e38ecd6
Fix parsing optional CSV ints (#842)
gdaviet Nov 9, 2025
fc30d69
Bump pytest from 8.4.2 to 9.0.0 (#844)
dependabot[bot] Nov 12, 2025
fb31ccb
Bump phonenumbers from 9.0.17 to 9.0.18 in the minor-updates group (#…
dependabot[bot] Nov 12, 2025
5aa88fc
Make payment token request atomic by locking db row (#845)
gdaviet Nov 12, 2025
b13edac
Bump pytest from 9.0.0 to 9.0.1 in the minor-updates group (#848)
dependabot[bot] Nov 22, 2025
a038732
Add waiting list mailing
gdaviet Nov 22, 2025
d57a3e5
Add option to show all skill badges on registration icons
gdaviet Nov 22, 2025
531edb3
Bump phonenumbers from 9.0.18 to 9.0.19 in the minor-updates group
dependabot[bot] Nov 24, 2025
94742e5
Force re-synchronization of unsettled payments (#851)
gdaviet Nov 26, 2025
69d0a39
Bulk import of badges (#850)
gdaviet Nov 26, 2025
c8281ee
Allow ";" for badge csv delimiter + reduce verbosity (#853)
gdaviet Nov 29, 2025
5fd0d0c
Fix error message when deleting events with restrictions (#854)
gdaviet Nov 29, 2025
dce233b
Fix adding volunteer badge to single user (#855)
gdaviet Nov 30, 2025
6ea2e6d
Stats: Add number of registrations per activity (#857)
gdaviet Dec 2, 2025
79265e5
Server-side filtering for badges, roles, and user events (#856)
gdaviet Dec 2, 2025
824bed5
Run ruff formatter
bbouffaut Dec 22, 2025
417e53e
Fix DB migration after master rebase
bbouffaut Dec 22, 2025
4a63b6f
Ruff updates
bbouffaut Dec 22, 2025
df95ff6
Read OAuth params from env variables
bbouffaut Dec 27, 2025
6894c7f
Load Auth0 config from env variables
bbouffaut Dec 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ uploads
.cache
*.egg-info

# Instance configuration with secrets
instance/config.py


doc/build

Expand Down
87 changes: 87 additions & 0 deletions AUTH0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Auth0 SSO - Documentation

La documentation Auth0 a été réorganisée dans le dossier `docs/auth0/`.

## 📚 Documentation disponible

### 🚀 [README.md](docs/auth0/README.md)
Guide complet de configuration et démarrage rapide
- Installation
- Configuration Auth0 Dashboard
- Variables d'environnement
- Modes de fonctionnement (optionnel, Force SSO, bypass admin)
- FAQ

### 🔄 [FLOWS.md](docs/auth0/FLOWS.md)
Diagrammes détaillés des 8 scénarios utilisateur
- Nouvel utilisateur (local/extranet)
- Utilisateur existant lie son compte
- Login Auth0
- Fallback mot de passe
- Webhook suppressions
- Admin bypass
- Social login
- Gestion d'erreurs

### 🚀 [DEPLOYMENT.md](docs/auth0/DEPLOYMENT.md)
Guide de déploiement production
- Configuration détaillée
- Migration base de données
- **Configuration du webhook Auth0** (étape par étape)
- Tests de validation
- Stratégie de déploiement progressif
- Troubleshooting
- Rollback

---

## ⚡ Quick Start

```bash
# 1. Un certain nombre de variables d'env variable sont à setup. Elles sont chargées dans instance/config.py
AUTH0_ENABLED=True
AUTH0_DOMAIN="your-tenant.eu.auth0.com"
AUTH0_CLIENT_ID="your_client_id"
AUTH0_CLIENT_SECRET="your_client_secret"

# Mode Force SSO (optionnel)
AUTH0_FORCE_SSO=True
AUTH0_BYPASS_ENABLED=True

# 2. Appliquer la migration
FLASK_APP=collectives:create_app uv run flask db upgrade

# 3. Démarrer
AUTH0_ENABLED=True AUTH0_DOMAIN="your-tenant.eu.auth0.com" AUTH0_CLIENT_ID="your_client_id" AUTH0_CLIENT_SECRET="your_client_secret" uv run python run.py
```

➡️ Consulter [docs/auth0/README.md](docs/auth0/README.md) pour plus de détails

---

## 🔑 Points importants

### Synchronisation des suppressions
**Auth0 → Collectives uniquement** (unidirectionnel)

- Supprimer un utilisateur dans Auth0 → désactive le compte Collectives
- Supprimer un utilisateur dans Collectives → AUCUN impact sur Auth0

Voir [DEPLOYMENT.md](docs/auth0/DEPLOYMENT.md) section "Webhooks" pour la configuration.

### Mot de passe
- **Mode extranet** : Pas de mot de passe (Auth0 uniquement)
- **Mode local** : Mot de passe saisi lors de l'inscription

### Rôles
Les rôles Collectives (encadrant, admin, etc.) restent gérés dans Collectives, pas dans Auth0.

---

## 📞 Support

Pour toute question :
1. Consulter la FAQ dans [docs/auth0/README.md](docs/auth0/README.md)
2. Vérifier le Troubleshooting dans [docs/auth0/DEPLOYMENT.md](docs/auth0/DEPLOYMENT.md)
3. Logs : `tail -f logs/collectives.log | grep auth0`

12 changes: 12 additions & 0 deletions collectives/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ def create_app(config_filename="config.py", extra_config=None):
payline.api.init_app(app)
csrf.init_app(app) # CSRF-protect non FLaskWTF views

# Initialize Auth0 OAuth if enabled
if app.config.get("AUTH0_ENABLED", False) and auth.init_oauth:
auth.init_oauth(app)

app.context_processor(jinja.helpers_processor)

_migrate = Migrate(app, models.db)
Expand Down Expand Up @@ -136,6 +140,14 @@ def create_app(config_filename="config.py", extra_config=None):
app.register_blueprint(reservation.blueprint)
app.register_blueprint(question.blueprint)

# Register Auth0 webhook blueprint if Auth0 is enabled
if app.config.get("AUTH0_ENABLED", False):
from collectives.api.auth0_webhooks import (
blueprint as auth0_webhooks_blueprint,
)

app.register_blueprint(auth0_webhooks_blueprint)

# Error handling
app.register_error_handler(werkzeug.exceptions.NotFound, error.not_found)
app.register_error_handler(
Expand Down
1 change: 1 addition & 0 deletions collectives/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import collectives.api.admin
import collectives.api.autocomplete_user
import collectives.api.auth0_webhooks
import collectives.api.equipment
import collectives.api.event
import collectives.api.models
Expand Down
184 changes: 160 additions & 24 deletions collectives/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import json

from flask import request, url_for
from flask import abort, request, url_for
from flask_login import current_user
from marshmallow import fields
from sqlalchemy import and_, desc
from sqlalchemy import and_, desc, or_
from sqlalchemy.orm import aliased, selectinload

from collectives.api.common import blueprint
Expand All @@ -15,8 +15,8 @@
UserIdentitySchema,
UserSchema,
)
from collectives.models import Badge, Role, RoleIds, User, db
from collectives.models.badge import BadgeIds
from collectives.models import ActivityType, Badge, Role, RoleIds, User, db
from collectives.models.badge import BadgeCustomLevel, BadgeIds
from collectives.utils.access import confidentiality_agreement, user_is, valid_user


Expand Down Expand Up @@ -248,12 +248,70 @@ def leaders():
query = db.session.query(Role)
query = query.filter(Role.role_id.in_(RoleIds.all_relates_to_activity()))
query = query.filter(Role.activity_id.in_(a.id for a in supervised_activities))
query = query.join(Role.user)
query = query.order_by(User.last_name, User.first_name, User.id)
query = query.join(Role.user).join(Role.activity_type)

response = LeaderRoleSchema(many=True).dump(query.all())
# Process filters coming from Tabulator (filters[0]...)
i = 0
while f"filters[{i}][field]" in request.args:
value = request.args.get(f"filters[{i}][value]")
field = request.args.get(f"filters[{i}][field]")
i += 1

if value is None:
continue

# support nested fields like 'user.full_name' or 'activity_type.name'
if "." in field:
prefix, sub = field.split(".", 1)
if prefix == "user":
if sub != "full_name":
continue
query_filter = User.full_name().ilike(f"%{value}%")
elif prefix == "activity_type":
try:
activity_id = int(value)
query_filter = Role.activity_id == activity_id
except ValueError:
continue
else:
continue
elif field == "name":
try:
role_id = int(value)
query_filter = Role.role_id == RoleIds(role_id)
except ValueError:
continue
else:
continue

return json.dumps(response), 200, {"content-type": "application/json"}
query = query.filter(query_filter)

# Sorting
if "sorters[0][field]" in request.args:
sort_field = request.args.get("sorters[0][field]")
sort_dir = request.args.get("sorters[0][dir]")
if sort_field == "user.full_name":
sort_field = User.full_name()
elif sort_field == "activity_type.name":
sort_field = ActivityType.name
elif sort_field == "name":
sort_field = Role.role_id
order = desc(sort_field) if sort_dir == "desc" else sort_field
query = query.order_by(order)
else:
query = query.order_by(User.last_name, User.first_name, User.id)

# Pagination (Tabulator expects 'page' and 'size')
page = int(request.args.get("page", 1))
size = int(request.args.get("size", 50))
paginated = query.paginate(page=page, per_page=size, error_out=False)

response = LeaderRoleSchema(many=True).dump(paginated.items)
return (
{"data": response, "last_page": paginated.pages},
200,
{"content-type": "application/json"},
)


@blueprint.route("/badges/")
Expand Down Expand Up @@ -284,27 +342,105 @@ def badges():
if badge_ids:
query = query.filter(Badge.badge_id.in_(badge_ids))
if supervised_activities:
query = query.filter(
Badge.activity_id.in_(a.id for a in supervised_activities),
)
query = query.filter(Badge.activity_id.in_(a.id for a in supervised_activities))

Recipient = aliased(User)
query = query.join(Recipient, Badge.user)
query = query.join(Badge.grantor, isouter=True)
query = query.order_by(Recipient.last_name, Recipient.first_name, Recipient.id)
query = query.join(Badge.activity_type, isouter=True)
query = query.join(
BadgeCustomLevel,
and_(
BadgeCustomLevel.badge_id == Badge.badge_id,
BadgeCustomLevel.level == Badge.level,
BadgeCustomLevel.activity_id.isnot_distinct_from(Badge.activity_id),
),
isouter=True,
)

badges_list = query.all()
# Process Tabulator filters
i = 0
while f"filters[{i}][field]" in request.args:
value = request.args.get(f"filters[{i}][value]")
field = request.args.get(f"filters[{i}][field]")
i += 1

for badge in badges_list:
badge.delete_uri = url_for(
request.args.get("delete", "activity_supervision.delete_volunteer"),
badge_id=badge.id,
)
badge.renew_uri = url_for(
request.args.get("renew", "activity_supervision.renew_volunteer"),
badge_id=badge.id,
)
if value is None:
continue

if "." in field:
prefix, sub = field.split(".", 1)
if prefix == "user":
if sub != "full_name":
continue
query_filter = Recipient.full_name().ilike(f"%{value}%")
elif prefix == "grantor":
if sub != "full_name":
continue
query_filter = User.full_name().ilike(f"%{value}%")
elif prefix == "activity_type":
try:
activity_id = int(value)
query_filter = Badge.activity_id == activity_id
except ValueError:
continue
else:
continue
elif field == "name":
try:
badge_id = int(value)
query_filter = Badge.badge_id == BadgeIds(badge_id)
except ValueError:
continue
elif field == "level":
value = value.lower()
default_levels = {
badge_id: [
level
for level, desc in badge_id.levels(only_defaults=True).items()
if value in desc.name.lower()
]
for badge_id in badge_ids or BadgeIds
}
default_level_clauses = [
and_(Badge.badge_id == badge_id, Badge.level.in_(levels))
for badge_id, levels in default_levels.items()
if levels
]
query_filter = or_(
*default_level_clauses,
BadgeCustomLevel.name.ilike(f"%{value}%"),
)
elif field in ("expiration_date",):
query_filter = getattr(Badge, field).ilike(f"%{value}%")

response = UserBadgeSchema(many=True).dump(badges_list)
query = query.filter(query_filter)

return json.dumps(response), 200, {"content-type": "application/json"}
# Sorting
if "sorters[0][field]" in request.args:
sort_field = request.args.get("sorters[0][field]")
sort_dir = request.args.get("sorters[0][dir]")
if sort_field == "user.full_name":
sort_field = Recipient.full_name()
elif sort_field == "grantor.full_name":
sort_field = User.full_name()
elif sort_field == "activity_type.name":
sort_field = ActivityType.name
elif sort_field == "name":
sort_field = Badge.badge_id
order = desc(sort_field) if sort_dir == "desc" else sort_field
query = query.order_by(order)
else:
query = query.order_by(Recipient.last_name, Recipient.first_name, Recipient.id)

# Pagination
page = int(request.args.get("page", 1))
size = int(request.args.get("size", 50))
paginated = query.paginate(page=page, per_page=size, error_out=False)

response = UserBadgeSchema(many=True).dump(paginated.items)
return (
{"data": response, "last_page": paginated.pages},
200,
{"content-type": "application/json"},
)
Loading
Loading