diff --git a/README.md b/README.md
index a6da0fe..fd60b9d 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
## À faire
+- traduire le message Rodauth « Please login to continue » ;
- limiter le spam sur `/contact/send` ;
-- développer les demandes d'adhésion et leur examen par le Noyau ;
-- développer l'espace personnel des membres ;
- afficher les recettes et les dépenses sur la page d'accueil ;
-- gérer les accès aux services selon la situation des membres.
+- gérer les accès aux services selon la situation des membres ;
+- traiter les demandes de démission et de suppression des comptes.
## Développement
@@ -85,6 +85,38 @@ Les personnes physiques et les personnes morales peuvent créer un compte.
La création du compte crée également une fiche dans `members`. L'adresse
électronique doit être vérifiée avant de pouvoir se connecter.
+### Espaces utilisateurs
+
+Après avoir vérifié son adresse électronique, chaque personne connectée
+accède à son espace personnel :
+
+```text
+http://[::1]:9292/account
+```
+
+Cet espace permet notamment de consulter sa situation d'adhésion et
+de formuler une demande d'adhésion à l'association.
+
+Les membres du Noyau disposent également d'un accès à la gestion de
+l'association :
+
+```text
+http://[::1]:9292/noyau
+```
+
+Cet espace permet notamment de consulter les demandes d'adhésion,
+de les accepter ou de les refuser, et de consulter les comptes
+de l'association.
+
+Les demandes en attente sont accessibles à l'adresse :
+
+```text
+http://[::1]:9292/noyau/memberships
+```
+
+La consultation et la gestion de l'association sont réservées
+aux membres du Noyau.
+
### Initialisation du Noyau
Pour ajouter une personne au Noyau, utiliser le script :
@@ -93,17 +125,22 @@ Pour ajouter une personne au Noyau, utiliser le script :
bundle exec ruby bin/add-core-member adresse@example.net
```
-Le compte doit exister, son adresse électronique doit être vérifiée et
-la fiche membre doit correspondre à une personne physique. Une adhésion
-au statut `accepted` doit également déjà exister dans `memberships`.
+Le compte doit exister, son adresse électronique doit être vérifiée
+et la fiche membre doit correspondre à une personne physique.
+Une adhésion au statut `accepted` doit également déjà exister
+dans `memberships`.
Le script ne crée ni le compte ni l'adhésion. Il attribue uniquement
la qualité de membre du Noyau à une fiche membre qui remplit ces
conditions. Il doit être exécuté séparément pour chaque personne.
-La gestion des demandes d'adhésion n'est pas encore disponible dans
-l'interface. Une adhésion acceptée doit donc être enregistrée dans la
-base avant l'utilisation de ce script.
+Les demandes d'adhésion sont formulées depuis l'espace personnel
+(`/account`) et examinées par le Noyau depuis l'espace de gestion
+de l'association (`/noyau/memberships`).
+
+Pour constituer le premier Noyau, les adhésions des personnes
+désignées doivent être enregistrées comme acceptées avant
+l'utilisation du script.
Les statuts prévoient un Noyau composé d'au moins trois personnes
physiques.
diff --git a/public/css/style.css b/public/css/style.css
index 9476521..393dfcf 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -621,6 +621,77 @@ summary:focus-visible {
vertical-align: top;
}
+/* Demandes d'adhésion
+------------------------------------------------------------------------ */
+
+.table-wrapper {
+ width: 100%;
+ margin-block: 1.5rem;
+}
+
+.memberships-table {
+ width: 100%;
+ table-layout: fixed;
+ font-size: 1rem;
+}
+
+.membership-col-identity {
+ width: 35%;
+}
+
+.membership-col-email {
+ width: 23%;
+}
+
+.membership-col-date {
+ width: 17%;
+}
+
+.membership-col-actions {
+ width: 25%;
+}
+
+.memberships-table th,
+.memberships-table td {
+ padding: 10px 8px;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+
+.memberships-table td strong,
+.membership-kind {
+ display: inline;
+}
+
+.membership-kind {
+ margin-left: 0.25rem;
+ color: var(--color-link);
+ font-size: 0.85rem;
+}
+
+.membership-date {
+ font-size: 0.9rem;
+}
+
+.membership-actions {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.membership-actions form {
+ flex: 0 0 auto;
+ margin: 0;
+}
+
+.membership-actions .button {
+ padding: 8px 10px;
+ font-size: 1rem;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
/* Pied de page
------------------------------------------------------------------------ */
@@ -679,6 +750,68 @@ footer {
}
}
+/* Adaptations pour les écrans intermédiaires et petits
+------------------------------------------------------------------------ */
+
+@media screen and (max-width: 1100px) {
+ .memberships-table,
+ .memberships-table tbody {
+ display: block;
+ }
+
+ .memberships-table thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ }
+
+ .memberships-table tbody {
+ display: grid;
+ gap: 1rem;
+ }
+
+ .memberships-table tr {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem 1rem;
+ padding: 1rem;
+ border: 1px solid var(--color-shadow-light);
+ background: var(--color-surface-dark);
+ }
+
+ .memberships-table td {
+ display: block;
+ min-width: 0;
+ padding: 0.35rem 0;
+ border: 0;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .memberships-table td::before {
+ display: block;
+ margin-bottom: 0.25rem;
+ color: var(--color-highlight);
+ font-weight: 700;
+ content: attr(data-label);
+ }
+
+ .memberships-table td[data-label="Décision"] {
+ grid-column: 1 / -1;
+ }
+
+ .membership-kind {
+ font-size: 0.9rem;
+ }
+
+ .membership-actions {
+ flex-wrap: wrap;
+ }
+}
+
/* Adaptations pour les petits écrans
------------------------------------------------------------------------ */
@@ -693,6 +826,22 @@ footer {
padding-bottom: 60px;
}
+ .memberships-table tr {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .memberships-table td[data-label="Décision"] {
+ grid-column: auto;
+ }
+
+ .membership-actions form {
+ flex: 1 1 auto;
+ }
+
+ .membership-actions .button {
+ width: 100%;
+ }
+
footer {
padding: 24px 20px;
}
diff --git a/routes/noyau.rb b/routes/noyau.rb
index 2fc9271..2fde317 100644
--- a/routes/noyau.rb
+++ b/routes/noyau.rb
@@ -1,21 +1,106 @@
class Netig
+ private
+
+ # Enregistrement d'une décision d'adhésion
+ def record_membership_decision!(r, membership_id, status)
+ member_id = DB[:members]
+ .where(account_id: rodauth.session_value)
+ .get(:id)
+
+ unless member_id
+ r.halt [403, {}, ["Accès interdit."]]
+ end
+
+ message = case status
+ when "accepted"
+ "La demande d'adhésion a été acceptée."
+ when "rejected"
+ "La demande d'adhésion a été refusée."
+ end
+
+ updated = DB[:memberships]
+ .where(id: membership_id, status: "pending")
+ .update(
+ status: status,
+ decided_at: Sequel::CURRENT_TIMESTAMP,
+ recorded_by: member_id
+ )
+
+ if updated == 1
+ flash["notice"] = message
+ else
+ flash["error"] = "Cette demande n'est plus en attente ou n'existe pas."
+ end
+
+ r.redirect "/noyau/memberships"
+ end
+
hash_branch "noyau" do |r|
require_core_member!(r)
- @page_title = "Noyau — NETig"
- @page_description = "Espace de gestion de l'association NETig."
+ @page_title = "Gestion de l'association — NETig"
+ @page_description = "Gestion collective de l'association NETig par le Noyau."
@canonical_url = "https://netig.net/noyau"
+ # Accueil
r.is do
r.get do
view "noyau"
end
end
+ # Demandes d'adhésion
+ r.on "memberships" do
+ r.is do
+ r.get do
+ @page_title = "Demandes d'adhésion — NETig"
+ @page_description = "Examen des demandes d'adhésion à NETig."
+ @canonical_url = "https://netig.net/noyau/memberships"
+
+ @memberships = DB[:memberships]
+ .join(:members, id: :member_id)
+ .join(:accounts, id: Sequel[:members][:account_id])
+ .where(
+ Sequel[:memberships][:status] => "pending",
+ Sequel[:accounts][:status_id] => 2
+ )
+ .select_all(:memberships)
+ .select_append(
+ Sequel[:members][:kind].as(:member_kind),
+ Sequel[:members][:first_name].as(:member_first_name),
+ Sequel[:members][:last_name].as(:member_last_name),
+ Sequel[:members][:organization_name].as(:member_organization_name),
+ Sequel[:accounts][:email].as(:account_email)
+ )
+ .order(
+ Sequel.asc(Sequel[:memberships][:requested_at]),
+ Sequel.asc(Sequel[:memberships][:id])
+ )
+ .all
+
+ view "noyau/memberships"
+ end
+ end
+
+ # Décision sur une demande
+ r.on Integer do |membership_id|
+ r.post "accept" do
+ record_membership_decision!(r, membership_id, "accepted")
+ end
+
+ r.post "reject" do
+ record_membership_decision!(r, membership_id, "rejected")
+ end
+ end
+ end
+
+ # Gestion des comptes
r.on "members" do
r.is do
r.get do
- @page_title = "Comptes — Noyau — NETig"
+ @page_title = "Comptes — NETig"
+ @page_description = "Consultation des comptes de l'association NETig."
+ @canonical_url = "https://netig.net/noyau/members"
@members = DB[:members]
.join(:accounts, id: :account_id)
diff --git a/views/account.erb b/views/account.erb
index 6605e26..ef60a4f 100644
--- a/views/account.erb
+++ b/views/account.erb
@@ -36,7 +36,7 @@
<% if @membership.nil? || @membership[:status] == "rejected" %>