judu/ex-egopublic Fork
⑂ d28a742
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(inscription): seconde porte, le lien d'invitationUnverified

SIGNUP_KEY ouvre une inscription libre-service, mais elle ne peut faire
atterrir que dans une équipe neutre : une clé partagée qui ferait rejoindre
une équipe existante ouvrirait les données de n'importe laquelle dont on
devine le nom. D'où cette seconde porte — un membre génère depuis Réglages
(POST /equipes/lien) un lien absolu vers /inscription?invitation=<biscuit>,
et le nouveau venu atterrit directement dans CETTE équipe, sans clé. Le
jeton est signé par la clé racine à la demande d'un membre : il vaut mieux
qu'un secret partagé, et la porte fonctionne même sans SIGNUP_KEY.

Deux jetons, deux durées, et surtout deux faits distincts : join_team(<id>)
pour le lien qui circule (1 h), team(<id>) pour le champ caché du POST
(10 min). Le lien passe par des messageries, des historiques, des
presse-papiers : il ne doit pas pouvoir être POSTé tel quel. Le fait porte
l'identifiant et jamais le nom — depuis la 0012 un join_team("Maison")
ferait atterrir dans n'importe laquelle. L'équipe d'accueil ne vit QUE dans
ces jetons signés : aucun champ caché en clair qu'on pourrait réécrire.

Jeton illisible, expiré, ou équipe supprimée entre-temps : AppError::
InviteExpired, une 400 qui invite à en redemander un.

routes::public_origin déduit l'origine publique de X-Forwarded-Host /
-Proto pour que le lien soit absolu, envoyable par message.
Julien Durillon committed 2026-08-19T15:29:33+02:00 Browse files
d28a742 parent: bc023fd
added src/auth/invite.rs +174 -0
new file mode 100644
@@ -0,0 +1,174 @@
1+//! Jetons d'invitation à une équipe — des biscuits signés par la même clé
2+//! racine que les sessions, mais porteurs d'un fait `join_team`.
3+//!
4+//! Deux durées ET deux faits distincts :
5+//! - **le lien** (1 h, `join_team`) : ce qu'un membre envoie à la personne
6+//! qu'il invite, dans la query string de `/inscription`.
7+//! - **le formulaire** (10 min, `team`) : forgé à l'atterrissage sur la page,
8+//! rendu en champ caché.
9+//!
10+//! Les faits diffèrent exprès : le lien circule par messagerie, historique de
11+//! navigation, presse-papier… et ne doit pas pouvoir être POSTé tel quel. Un
12+//! jeton de lien ne vaut donc rien au POST, et un jeton de formulaire ne
13+//! rouvre pas la page. L'équipe d'accueil ne vit QUE dans ces jetons signés —
14+//! pas de champ caché en clair qu'on pourrait réécrire.
15+//!
16+//! ⚠ Le fait porte l'**identifiant** de l'équipe, pas son nom : depuis la
17+//! migration 0012 les noms ne sont plus uniques, et un `join_team("Maison")`
18+//! ferait atterrir dans n'importe quelle « Maison ». Le nom voyage à côté
19+//! (`team_name`) pour rester lisible à l'inspection du jeton, mais il ne fait
20+//! jamais autorité.
21+//!
22+//! Un biscuit de session ne peut pas servir ici (il n'a pas de `join_team`) ni
23+//! l'inverse (pas de `user`) : les deux autorisations sont disjointes.
24+
25+use std::collections::HashMap;
26+use std::time::{Duration, SystemTime, UNIX_EPOCH};
27+
28+use anyhow::{Context, Result, anyhow};
29+use biscuit_auth::builder::{AuthorizerBuilder, Term};
30+use biscuit_auth::datalog::RunLimits;
31+use biscuit_auth::{Biscuit, KeyPair, PublicKey};
32+use uuid::Uuid;
33+
34+/// Durée de vie du lien qu'on partage.
35+pub const LINK_SECONDS: u64 = 3600;
36+/// Durée de vie du jeton posé dans le formulaire.
37+pub const FORM_SECONDS: u64 = 600;
38+
39+/// Le lien partagé : `join_team(<uuid>)`, une heure.
40+pub fn mint_link(root: &KeyPair, team_id: Uuid, team_name: &str) -> Result<String> {
41+ mint(root, "join_team", team_id, team_name, LINK_SECONDS)
42+}
43+
44+/// Le champ caché du formulaire : `team(<uuid>)`, dix minutes.
45+pub fn mint_form(root: &KeyPair, team_id: Uuid, team_name: &str) -> Result<String> {
46+ mint(root, "team", team_id, team_name, FORM_SECONDS)
47+}
48+
49+/// Vérifie un jeton de LIEN et rend l'équipe autorisée.
50+pub fn verify_link(token_b64: &str, public: PublicKey) -> Result<Uuid> {
51+ verify(token_b64, public, "join_team")
52+}
53+
54+/// Vérifie un jeton de FORMULAIRE et rend l'équipe autorisée.
55+pub fn verify_form(token_b64: &str, public: PublicKey) -> Result<Uuid> {
56+ verify(token_b64, public, "team")
57+}
58+
59+fn mint(
60+ root: &KeyPair,
61+ fact: &str,
62+ team_id: Uuid,
63+ team_name: &str,
64+ ttl_secs: u64,
65+) -> Result<String> {
66+ let expiry = SystemTime::now() + Duration::from_secs(ttl_secs);
67+ let expiry_secs = expiry
68+ .duration_since(UNIX_EPOCH)
69+ .context("horloge système antérieure à 1970")?
70+ .as_secs();
71+ let builder = Biscuit::builder().code_with_params(
72+ // Le nom du fait vient d'une constante du module, jamais d'une entrée
73+ // utilisateur — il ne peut pas être injecté.
74+ &format!("{fact}({{id}}); team_name({{name}}); check if time($t), $t < {{exp}};"),
75+ HashMap::from([
76+ ("id".to_string(), Term::Str(team_id.to_string())),
77+ ("name".to_string(), Term::Str(team_name.to_string())),
78+ ("exp".to_string(), Term::Date(expiry_secs)),
79+ ]),
80+ HashMap::new(),
81+ )?;
82+ Ok(builder.build(root)?.to_base64()?)
83+}
84+
85+/// Vérifie signature + expiration et rend l'identifiant d'équipe autorisé.
86+/// L'appelant DOIT encore vérifier que l'équipe existe : rien n'empêche
87+/// qu'elle ait été supprimée depuis l'émission du lien.
88+fn verify(token_b64: &str, public: PublicKey, fact: &str) -> Result<Uuid> {
89+ let biscuit = Biscuit::from_base64(token_b64, public)?;
90+
91+ let mut authorizer = AuthorizerBuilder::new()
92+ .time()
93+ .set_limits(RunLimits {
94+ max_time: Duration::from_millis(100),
95+ ..Default::default()
96+ })
97+ .code(format!("allow if {fact}($t);"))?
98+ .build(&biscuit)?;
99+ authorizer.authorize()?;
100+
101+ let rule = format!("data($id) <- {fact}($id)");
102+ let ids: Vec<(String,)> = authorizer.query(rule.as_str())?;
103+ let (id_str,) = ids.first().ok_or_else(|| anyhow!("fait {fact} absent"))?;
104+ Uuid::parse_str(id_str).with_context(|| format!("{fact} ne porte pas un UUID"))
105+}
106+
107+#[cfg(test)]
108+mod tests {
109+ use super::*;
110+
111+ #[test]
112+ fn mint_and_verify() {
113+ let kp = KeyPair::new();
114+ let team = Uuid::now_v7();
115+ assert_eq!(
116+ verify_link(&mint_link(&kp, team, "Maison").unwrap(), kp.public()).unwrap(),
117+ team
118+ );
119+ assert_eq!(
120+ verify_form(&mint_form(&kp, team, "Maison").unwrap(), kp.public()).unwrap(),
121+ team
122+ );
123+ }
124+
125+ /// Le cœur de la séparation : le lien circule (messagerie, historique,
126+ /// presse-papier) et ne doit jamais valoir laissez-passer au POST.
127+ #[test]
128+ fn link_and_form_tokens_do_not_cross() {
129+ let kp = KeyPair::new();
130+ let team = Uuid::now_v7();
131+ let link = mint_link(&kp, team, "Maison").unwrap();
132+ let form = mint_form(&kp, team, "Maison").unwrap();
133+ assert!(verify_form(&link, kp.public()).is_err(), "le lien ne vaut pas formulaire");
134+ assert!(verify_link(&form, kp.public()).is_err(), "le formulaire ne vaut pas lien");
135+ }
136+
137+ #[test]
138+ fn rejects_wrong_key() {
139+ let kp = KeyPair::new();
140+ let token = mint_link(&kp, Uuid::now_v7(), "Maison").unwrap();
141+ assert!(verify_link(&token, KeyPair::new().public()).is_err());
142+ }
143+
144+ #[test]
145+ fn rejects_expired() {
146+ let kp = KeyPair::new();
147+ // ttl 0 : l'expiration est déjà passée au moment de la vérification.
148+ let token = mint(&kp, "join_team", Uuid::now_v7(), "Maison", 0).unwrap();
149+ assert!(verify_link(&token, kp.public()).is_err());
150+ }
151+
152+ /// Étanchéité avec les sessions : un biscuit `user(...)` ne vaut pas
153+ /// invitation, et une invitation ne vaut pas session.
154+ #[test]
155+ fn session_and_invite_tokens_do_not_cross() {
156+ let kp = KeyPair::new();
157+ let session = crate::auth::token::mint(&kp, Uuid::now_v7()).unwrap();
158+ assert!(verify_link(&session, kp.public()).is_err());
159+ assert!(verify_form(&session, kp.public()).is_err());
160+
161+ let invite = mint_link(&kp, Uuid::now_v7(), "Maison").unwrap();
162+ assert!(crate::auth::token::verify(&invite, kp.public()).is_err());
163+ }
164+
165+ #[test]
166+ fn rejects_tampered() {
167+ let kp = KeyPair::new();
168+ let token = mint_link(&kp, Uuid::now_v7(), "Maison").unwrap();
169+ let mut bytes = token.into_bytes();
170+ let last = bytes.len() - 1;
171+ bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
172+ assert!(verify_link(&String::from_utf8(bytes).unwrap(), kp.public()).is_err());
173+ }
174+}
new file mode 100644
@@ -0,0 +1,174 @@
1+//! Jetons d'invitation à une équipe — des biscuits signés par la même clé
2+//! racine que les sessions, mais porteurs d'un fait `join_team`.
3+//!
4+//! Deux durées ET deux faits distincts :
5+//! - **le lien** (1 h, `join_team`) : ce qu'un membre envoie à la personne
6+//! qu'il invite, dans la query string de `/inscription`.
7+//! - **le formulaire** (10 min, `team`) : forgé à l'atterrissage sur la page,
8+//! rendu en champ caché.
9+//!
10+//! Les faits diffèrent exprès : le lien circule par messagerie, historique de
11+//! navigation, presse-papier… et ne doit pas pouvoir être POSTé tel quel. Un
12+//! jeton de lien ne vaut donc rien au POST, et un jeton de formulaire ne
13+//! rouvre pas la page. L'équipe d'accueil ne vit QUE dans ces jetons signés —
14+//! pas de champ caché en clair qu'on pourrait réécrire.
15+//!
16+//! ⚠ Le fait porte l'**identifiant** de l'équipe, pas son nom : depuis la
17+//! migration 0012 les noms ne sont plus uniques, et un `join_team("Maison")`
18+//! ferait atterrir dans n'importe quelle « Maison ». Le nom voyage à côté
19+//! (`team_name`) pour rester lisible à l'inspection du jeton, mais il ne fait
20+//! jamais autorité.
21+//!
22+//! Un biscuit de session ne peut pas servir ici (il n'a pas de `join_team`) ni
23+//! l'inverse (pas de `user`) : les deux autorisations sont disjointes.
24+
25+use std::collections::HashMap;
26+use std::time::{Duration, SystemTime, UNIX_EPOCH};
27+
28+use anyhow::{Context, Result, anyhow};
29+use biscuit_auth::builder::{AuthorizerBuilder, Term};
30+use biscuit_auth::datalog::RunLimits;
31+use biscuit_auth::{Biscuit, KeyPair, PublicKey};
32+use uuid::Uuid;
33+
34+/// Durée de vie du lien qu'on partage.
35+pub const LINK_SECONDS: u64 = 3600;
36+/// Durée de vie du jeton posé dans le formulaire.
37+pub const FORM_SECONDS: u64 = 600;
38+
39+/// Le lien partagé : `join_team(<uuid>)`, une heure.
40+pub fn mint_link(root: &KeyPair, team_id: Uuid, team_name: &str) -> Result<String> {
41+ mint(root, "join_team", team_id, team_name, LINK_SECONDS)
42+}
43+
44+/// Le champ caché du formulaire : `team(<uuid>)`, dix minutes.
45+pub fn mint_form(root: &KeyPair, team_id: Uuid, team_name: &str) -> Result<String> {
46+ mint(root, "team", team_id, team_name, FORM_SECONDS)
47+}
48+
49+/// Vérifie un jeton de LIEN et rend l'équipe autorisée.
50+pub fn verify_link(token_b64: &str, public: PublicKey) -> Result<Uuid> {
51+ verify(token_b64, public, "join_team")
52+}
53+
54+/// Vérifie un jeton de FORMULAIRE et rend l'équipe autorisée.
55+pub fn verify_form(token_b64: &str, public: PublicKey) -> Result<Uuid> {
56+ verify(token_b64, public, "team")
57+}
58+
59+fn mint(
60+ root: &KeyPair,
61+ fact: &str,
62+ team_id: Uuid,
63+ team_name: &str,
64+ ttl_secs: u64,
65+) -> Result<String> {
66+ let expiry = SystemTime::now() + Duration::from_secs(ttl_secs);
67+ let expiry_secs = expiry
68+ .duration_since(UNIX_EPOCH)
69+ .context("horloge système antérieure à 1970")?
70+ .as_secs();
71+ let builder = Biscuit::builder().code_with_params(
72+ // Le nom du fait vient d'une constante du module, jamais d'une entrée
73+ // utilisateur — il ne peut pas être injecté.
74+ &format!("{fact}({{id}}); team_name({{name}}); check if time($t), $t < {{exp}};"),
75+ HashMap::from([
76+ ("id".to_string(), Term::Str(team_id.to_string())),
77+ ("name".to_string(), Term::Str(team_name.to_string())),
78+ ("exp".to_string(), Term::Date(expiry_secs)),
79+ ]),
80+ HashMap::new(),
81+ )?;
82+ Ok(builder.build(root)?.to_base64()?)
83+}
84+
85+/// Vérifie signature + expiration et rend l'identifiant d'équipe autorisé.
86+/// L'appelant DOIT encore vérifier que l'équipe existe : rien n'empêche
87+/// qu'elle ait été supprimée depuis l'émission du lien.
88+fn verify(token_b64: &str, public: PublicKey, fact: &str) -> Result<Uuid> {
89+ let biscuit = Biscuit::from_base64(token_b64, public)?;
90+
91+ let mut authorizer = AuthorizerBuilder::new()
92+ .time()
93+ .set_limits(RunLimits {
94+ max_time: Duration::from_millis(100),
95+ ..Default::default()
96+ })
97+ .code(format!("allow if {fact}($t);"))?
98+ .build(&biscuit)?;
99+ authorizer.authorize()?;
100+
101+ let rule = format!("data($id) <- {fact}($id)");
102+ let ids: Vec<(String,)> = authorizer.query(rule.as_str())?;
103+ let (id_str,) = ids.first().ok_or_else(|| anyhow!("fait {fact} absent"))?;
104+ Uuid::parse_str(id_str).with_context(|| format!("{fact} ne porte pas un UUID"))
105+}
106+
107+#[cfg(test)]
108+mod tests {
109+ use super::*;
110+
111+ #[test]
112+ fn mint_and_verify() {
113+ let kp = KeyPair::new();
114+ let team = Uuid::now_v7();
115+ assert_eq!(
116+ verify_link(&mint_link(&kp, team, "Maison").unwrap(), kp.public()).unwrap(),
117+ team
118+ );
119+ assert_eq!(
120+ verify_form(&mint_form(&kp, team, "Maison").unwrap(), kp.public()).unwrap(),
121+ team
122+ );
123+ }
124+
125+ /// Le cœur de la séparation : le lien circule (messagerie, historique,
126+ /// presse-papier) et ne doit jamais valoir laissez-passer au POST.
127+ #[test]
128+ fn link_and_form_tokens_do_not_cross() {
129+ let kp = KeyPair::new();
130+ let team = Uuid::now_v7();
131+ let link = mint_link(&kp, team, "Maison").unwrap();
132+ let form = mint_form(&kp, team, "Maison").unwrap();
133+ assert!(verify_form(&link, kp.public()).is_err(), "le lien ne vaut pas formulaire");
134+ assert!(verify_link(&form, kp.public()).is_err(), "le formulaire ne vaut pas lien");
135+ }
136+
137+ #[test]
138+ fn rejects_wrong_key() {
139+ let kp = KeyPair::new();
140+ let token = mint_link(&kp, Uuid::now_v7(), "Maison").unwrap();
141+ assert!(verify_link(&token, KeyPair::new().public()).is_err());
142+ }
143+
144+ #[test]
145+ fn rejects_expired() {
146+ let kp = KeyPair::new();
147+ // ttl 0 : l'expiration est déjà passée au moment de la vérification.
148+ let token = mint(&kp, "join_team", Uuid::now_v7(), "Maison", 0).unwrap();
149+ assert!(verify_link(&token, kp.public()).is_err());
150+ }
151+
152+ /// Étanchéité avec les sessions : un biscuit `user(...)` ne vaut pas
153+ /// invitation, et une invitation ne vaut pas session.
154+ #[test]
155+ fn session_and_invite_tokens_do_not_cross() {
156+ let kp = KeyPair::new();
157+ let session = crate::auth::token::mint(&kp, Uuid::now_v7()).unwrap();
158+ assert!(verify_link(&session, kp.public()).is_err());
159+ assert!(verify_form(&session, kp.public()).is_err());
160+
161+ let invite = mint_link(&kp, Uuid::now_v7(), "Maison").unwrap();
162+ assert!(crate::auth::token::verify(&invite, kp.public()).is_err());
163+ }
164+
165+ #[test]
166+ fn rejects_tampered() {
167+ let kp = KeyPair::new();
168+ let token = mint_link(&kp, Uuid::now_v7(), "Maison").unwrap();
169+ let mut bytes = token.into_bytes();
170+ let last = bytes.len() - 1;
171+ bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
172+ assert!(verify_link(&String::from_utf8(bytes).unwrap(), kp.public()).is_err());
173+ }
174+}
modified src/auth/mod.rs +1 -0
@@ -1,5 +1,6 @@
11 pub mod csrf;
22 pub mod extractor;
3+pub mod invite;
34 pub mod password;
45 pub mod revocation;
56 pub mod token;
@@ -1,5 +1,6 @@
1 pub mod csrf;1 pub mod csrf;
2 pub mod extractor;2 pub mod extractor;
3+pub mod invite;
3 pub mod password;4 pub mod password;
4 pub mod revocation;5 pub mod revocation;
5 pub mod token;6 pub mod token;
modified src/db/teams.rs +0 -9
@@ -164,15 +164,6 @@ pub async fn delete_if_empty(
164164 Ok(res.rows_affected() > 0)
165165 }
166166
167-pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
168- let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
169- .bind(team_id)
170- .bind(user_id)
171- .execute(pool)
172- .await?;
173- Ok(res.rows_affected())
174-}
175-
176167 /// (id, nom) si l'utilisateur est membre de cette équipe — valide le cookie.
177168 pub async fn membership(
178169 pool: &PgPool,
@@ -164,15 +164,6 @@ pub async fn delete_if_empty(
164 Ok(res.rows_affected() > 0)164 Ok(res.rows_affected() > 0)
165 }165 }
166 166
167-pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
168- let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
169- .bind(team_id)
170- .bind(user_id)
171- .execute(pool)
172- .await?;
173- Ok(res.rows_affected())
174-}
175-
176 /// (id, nom) si l'utilisateur est membre de cette équipe — valide le cookie.167 /// (id, nom) si l'utilisateur est membre de cette équipe — valide le cookie.
177 pub async fn membership(168 pub async fn membership(
178 pool: &PgPool,169 pool: &PgPool,
modified src/db/users.rs +25 -7
@@ -86,7 +86,16 @@ pub enum Signup {
8686 UsernameTaken,
8787 }
8888
89-/// Inscription libre-service : le compte ET son **équipe neutre**, en une
89+/// Équipe d'accueil du nouveau compte.
90+pub enum InitialTeam {
91+ /// Inscription par la clé : une équipe neutre à son nom, qu'il quittera
92+ /// (et qui disparaîtra) le jour où on l'invite ailleurs.
93+ Neutral,
94+ /// Inscription par lien d'invitation : l'équipe portée par le biscuit.
95+ Existing(Uuid),
96+}
97+
98+/// Inscription libre-service : le compte ET son équipe, en une
9099 /// transaction (invariant « jamais d'utilisateur sans équipe »).
91100 ///
92101 /// Le formulaire ne propose PAS de rejoindre une équipe existante : la clé
@@ -106,6 +115,7 @@ pub async fn signup(
106115 username: &str,
107116 password_hash: &str,
108117 lang: &str,
118+ team: InitialTeam,
109119 ) -> sqlx::Result<Signup> {
110120 let user_id = Uuid::now_v7();
111121 let mut tx = pool.begin().await?;
@@ -125,12 +135,20 @@ pub async fn signup(
125135 return Ok(Signup::UsernameTaken);
126136 }
127137
128- let team_id = Uuid::now_v7();
129- sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
130- .bind(team_id)
131- .bind(username)
132- .execute(&mut *tx)
133- .await?;
138+ let team_id = match team {
139+ InitialTeam::Existing(id) => id,
140+ InitialTeam::Neutral => {
141+ let id = Uuid::now_v7();
142+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
143+ .bind(id)
144+ .bind(username)
145+ .execute(&mut *tx)
146+ .await?;
147+ id
148+ }
149+ };
150+ // Équipe supprimée entre la vérification du jeton et ici : la clé
151+ // étrangère fait retomber la transaction, pas de compte orphelin.
134152 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
135153 .bind(team_id)
136154 .bind(user_id)
@@ -86,7 +86,16 @@ pub enum Signup {
86 UsernameTaken,86 UsernameTaken,
87 }87 }
88 88
89-/// Inscription libre-service : le compte ET son **équipe neutre**, en une89+/// Équipe d'accueil du nouveau compte.
90+pub enum InitialTeam {
91+ /// Inscription par la clé : une équipe neutre à son nom, qu'il quittera
92+ /// (et qui disparaîtra) le jour où on l'invite ailleurs.
93+ Neutral,
94+ /// Inscription par lien d'invitation : l'équipe portée par le biscuit.
95+ Existing(Uuid),
96+}
97+
98+/// Inscription libre-service : le compte ET son équipe, en une
90 /// transaction (invariant « jamais d'utilisateur sans équipe »).99 /// transaction (invariant « jamais d'utilisateur sans équipe »).
91 ///100 ///
92 /// Le formulaire ne propose PAS de rejoindre une équipe existante : la clé101 /// Le formulaire ne propose PAS de rejoindre une équipe existante : la clé
@@ -106,6 +115,7 @@ pub async fn signup(
106 username: &str,115 username: &str,
107 password_hash: &str,116 password_hash: &str,
108 lang: &str,117 lang: &str,
118+ team: InitialTeam,
109 ) -> sqlx::Result<Signup> {119 ) -> sqlx::Result<Signup> {
110 let user_id = Uuid::now_v7();120 let user_id = Uuid::now_v7();
111 let mut tx = pool.begin().await?;121 let mut tx = pool.begin().await?;
@@ -125,12 +135,20 @@ pub async fn signup(
125 return Ok(Signup::UsernameTaken);135 return Ok(Signup::UsernameTaken);
126 }136 }
127 137
128- let team_id = Uuid::now_v7();138+ let team_id = match team {
129- sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")139+ InitialTeam::Existing(id) => id,
130- .bind(team_id)140+ InitialTeam::Neutral => {
131- .bind(username)141+ let id = Uuid::now_v7();
132- .execute(&mut *tx)142+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
133- .await?;143+ .bind(id)
144+ .bind(username)
145+ .execute(&mut *tx)
146+ .await?;
147+ id
148+ }
149+ };
150+ // Équipe supprimée entre la vérification du jeton et ici : la clé
151+ // étrangère fait retomber la transaction, pas de compte orphelin.
134 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")152 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
135 .bind(team_id)153 .bind(team_id)
136 .bind(user_id)154 .bind(user_id)
modified src/error.rs +5 -0
@@ -11,6 +11,8 @@ pub enum AppError {
1111 NotFound,
1212 #[error("formulaire invalide : {0}")]
1313 BadRequest(String),
14+ #[error("lien d'invitation périmé")]
15+ InviteExpired,
1416 #[error("jeton CSRF invalide")]
1517 Csrf,
1618 #[error("{0}")]
@@ -24,6 +26,9 @@ impl IntoResponse for AppError {
2426 let (status, message) = match &self {
2527 AppError::NotFound => (StatusCode::NOT_FOUND, tr.err_404.to_string()),
2628 AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
29+ // Lien expiré, illisible, ou équipe disparue depuis l'émission :
30+ // trois façons d'arriver trop tard, un seul message.
31+ AppError::InviteExpired => (StatusCode::BAD_REQUEST, tr.err_invite_expired.to_string()),
2732 AppError::Csrf => (StatusCode::FORBIDDEN, tr.err_csrf.to_string()),
2833 _ => {
2934 tracing::error!(error = %self, "erreur interne");
@@ -11,6 +11,8 @@ pub enum AppError {
11 NotFound,11 NotFound,
12 #[error("formulaire invalide : {0}")]12 #[error("formulaire invalide : {0}")]
13 BadRequest(String),13 BadRequest(String),
14+ #[error("lien d'invitation périmé")]
15+ InviteExpired,
14 #[error("jeton CSRF invalide")]16 #[error("jeton CSRF invalide")]
15 Csrf,17 Csrf,
16 #[error("{0}")]18 #[error("{0}")]
@@ -24,6 +26,9 @@ impl IntoResponse for AppError {
24 let (status, message) = match &self {26 let (status, message) = match &self {
25 AppError::NotFound => (StatusCode::NOT_FOUND, tr.err_404.to_string()),27 AppError::NotFound => (StatusCode::NOT_FOUND, tr.err_404.to_string()),
26 AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),28 AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
29+ // Lien expiré, illisible, ou équipe disparue depuis l'émission :
30+ // trois façons d'arriver trop tard, un seul message.
31+ AppError::InviteExpired => (StatusCode::BAD_REQUEST, tr.err_invite_expired.to_string()),
27 AppError::Csrf => (StatusCode::FORBIDDEN, tr.err_csrf.to_string()),32 AppError::Csrf => (StatusCode::FORBIDDEN, tr.err_csrf.to_string()),
28 _ => {33 _ => {
29 tracing::error!(error = %self, "erreur interne");34 tracing::error!(error = %self, "erreur interne");
modified src/i18n.rs +40 -8
@@ -280,6 +280,14 @@ pub struct Tr {
280280 pub signup_team_hint: &'static str,
281281 pub err_signup_rate_limited: &'static str,
282282 pub err_signup_too_many_attempts: &'static str,
283+ /// Page 400 d'un lien d'invitation périmé (page anonyme, DEFAULT_LANG).
284+ pub err_invite_expired: &'static str,
285+ // ─── lien d'invitation (Réglages) ───
286+ pub invite_link: &'static str,
287+ pub invite_link_hint: &'static str,
288+ pub invite_link_generate: &'static str,
289+ pub invite_link_ready: &'static str,
290+ pub signup_joining_pat: &'static str,
283291 pub err_password_mismatch: &'static str,
284292 pub err_password_wrong: &'static str,
285293 pub invite_unknown_pat: &'static str,
@@ -500,8 +508,8 @@ pub static FR: Tr = Tr {
500508 accept: "Accepter",
501509 refuse: "Refuser",
502510 account_kicker: "Compte",
503- account_line_pat: "Membre de {} équipes. Les comptes sont créés en ligne de commande.",
504- account_line_single: "Les comptes sont créés en ligne de commande.",
511+ account_line_pat: "Membre de {} équipes.",
512+ account_line_single: "Membre d'une seule équipe.",
505513 my_entries: "Mes saisies",
506514 my_entries_desc: "Consulter et corriger les temps que j'ai enregistrés.",
507515 manage_categories_desc: "Ajouter, renommer ou archiver les catégories de l'équipe.",
@@ -569,6 +577,12 @@ pub static FR: Tr = Tr {
569577 signup_team_hint: "Ton compte démarre avec une équipe à toi, à ton nom. Tu pourras la renommer, et quand on t'invitera dans une autre, tu pourras la quitter — vide, elle disparaîtra.",
570578 err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.",
571579 err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.",
580+ err_invite_expired: "Ce lien d'invitation a expiré ou n'est plus valable. Demandes-en un nouveau à la personne qui t'a invité.",
581+ invite_link: "Lien d'invitation",
582+ invite_link_hint: "Pour quelqu'un qui n'a pas encore de compte : le lien l'amène directement à l'inscription dans cette équipe. Il vaut une heure.",
583+ invite_link_generate: "Générer un lien",
584+ invite_link_ready: "Copie-le et envoie-le. Il expire dans une heure.",
585+ signup_joining_pat: "Tu rejoins l'équipe {}.",
572586 err_password_mismatch: "La confirmation ne correspond pas.",
573587 err_password_wrong: "Mot de passe actuel incorrect.",
574588 invite_unknown_pat: "Aucun compte « {} ».",
@@ -759,8 +773,8 @@ pub static EN: Tr = Tr {
759773 accept: "Accept",
760774 refuse: "Decline",
761775 account_kicker: "Account",
762- account_line_pat: "Member of {} teams. Accounts are created from the command line.",
763- account_line_single: "Accounts are created from the command line.",
776+ account_line_pat: "Member of {} teams.",
777+ account_line_single: "Member of a single team.",
764778 my_entries: "My entries",
765779 my_entries_desc: "View and fix the times I recorded.",
766780 manage_categories_desc: "Add, rename or archive the team's categories.",
@@ -828,6 +842,12 @@ pub static EN: Tr = Tr {
828842 signup_team_hint: "Your account starts with a team of your own, named after you. You can rename it, and once someone invites you to another one you can leave it — empty, it disappears.",
829843 err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.",
830844 err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.",
845+ err_invite_expired: "This invitation link has expired or is no longer valid. Ask whoever invited you for a new one.",
846+ invite_link: "Invitation link",
847+ invite_link_hint: "For someone who has no account yet: the link takes them straight to signup in this team. It lasts one hour.",
848+ invite_link_generate: "Generate a link",
849+ invite_link_ready: "Copy it and send it. It expires in an hour.",
850+ signup_joining_pat: "You are joining the {} team.",
831851 err_password_mismatch: "The confirmation does not match.",
832852 err_password_wrong: "Current password is incorrect.",
833853 invite_unknown_pat: "No account “{}”.",
@@ -1018,8 +1038,8 @@ pub static DE: Tr = Tr {
10181038 accept: "Annehmen",
10191039 refuse: "Ablehnen",
10201040 account_kicker: "Konto",
1021- account_line_pat: "Mitglied in {} Teams. Konten werden über die Kommandozeile angelegt.",
1022- account_line_single: "Konten werden über die Kommandozeile angelegt.",
1041+ account_line_pat: "Mitglied in {} Teams.",
1042+ account_line_single: "Mitglied in einem einzigen Team.",
10231043 my_entries: "Meine Einträge",
10241044 my_entries_desc: "Erfasste Zeiten ansehen und korrigieren.",
10251045 manage_categories_desc: "Kategorien des Teams hinzufügen, umbenennen oder archivieren.",
@@ -1087,6 +1107,12 @@ pub static DE: Tr = Tr {
10871107 signup_team_hint: "Dein Konto startet mit einem eigenen Team unter deinem Namen. Du kannst es umbenennen, und sobald dich jemand in ein anderes einlädt, kannst du es verlassen — leer verschwindet es.",
10881108 err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.",
10891109 err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.",
1110+ err_invite_expired: "Dieser Einladungslink ist abgelaufen oder nicht mehr gültig. Bitte um einen neuen.",
1111+ invite_link: "Einladungslink",
1112+ invite_link_hint: "Für jemanden ohne Konto: Der Link führt direkt zur Anmeldung in diesem Team. Er gilt eine Stunde.",
1113+ invite_link_generate: "Link erzeugen",
1114+ invite_link_ready: "Kopier ihn und schick ihn. Er läuft in einer Stunde ab.",
1115+ signup_joining_pat: "Du trittst dem Team {} bei.",
10901116 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",
10911117 err_password_wrong: "Aktuelles Passwort ist falsch.",
10921118 invite_unknown_pat: "Kein Konto „{}“.",
@@ -1277,8 +1303,8 @@ pub static PT_BR: Tr = Tr {
12771303 accept: "Aceitar",
12781304 refuse: "Recusar",
12791305 account_kicker: "Conta",
1280- account_line_pat: "Membro de {} equipes. As contas são criadas pela linha de comando.",
1281- account_line_single: "As contas são criadas pela linha de comando.",
1306+ account_line_pat: "Membro de {} equipes.",
1307+ account_line_single: "Membro de uma única equipe.",
12821308 my_entries: "Meus registros",
12831309 my_entries_desc: "Ver e corrigir os tempos que eu registrei.",
12841310 manage_categories_desc: "Adicionar, renomear ou arquivar as categorias da equipe.",
@@ -1346,6 +1372,12 @@ pub static PT_BR: Tr = Tr {
13461372 signup_team_hint: "Sua conta começa com uma equipe sua, com o seu nome. Você pode renomeá-la e, quando alguém te convidar para outra, pode sair dela — vazia, ela desaparece.",
13471373 err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.",
13481374 err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.",
1375+ err_invite_expired: "Este link de convite expirou ou não vale mais. Peça um novo a quem te convidou.",
1376+ invite_link: "Link de convite",
1377+ invite_link_hint: "Para quem ainda não tem conta: o link leva direto ao cadastro nesta equipe. Vale uma hora.",
1378+ invite_link_generate: "Gerar um link",
1379+ invite_link_ready: "Copie e envie. Expira em uma hora.",
1380+ signup_joining_pat: "Você está entrando na equipe {}.",
13491381 err_password_mismatch: "A confirmação não confere.",
13501382 err_password_wrong: "Senha atual incorreta.",
13511383 invite_unknown_pat: "Nenhuma conta “{}”.",
@@ -280,6 +280,14 @@ pub struct Tr {
280 pub signup_team_hint: &'static str,280 pub signup_team_hint: &'static str,
281 pub err_signup_rate_limited: &'static str,281 pub err_signup_rate_limited: &'static str,
282 pub err_signup_too_many_attempts: &'static str,282 pub err_signup_too_many_attempts: &'static str,
283+ /// Page 400 d'un lien d'invitation périmé (page anonyme, DEFAULT_LANG).
284+ pub err_invite_expired: &'static str,
285+ // ─── lien d'invitation (Réglages) ───
286+ pub invite_link: &'static str,
287+ pub invite_link_hint: &'static str,
288+ pub invite_link_generate: &'static str,
289+ pub invite_link_ready: &'static str,
290+ pub signup_joining_pat: &'static str,
283 pub err_password_mismatch: &'static str,291 pub err_password_mismatch: &'static str,
284 pub err_password_wrong: &'static str,292 pub err_password_wrong: &'static str,
285 pub invite_unknown_pat: &'static str,293 pub invite_unknown_pat: &'static str,
@@ -500,8 +508,8 @@ pub static FR: Tr = Tr {
500 accept: "Accepter",508 accept: "Accepter",
501 refuse: "Refuser",509 refuse: "Refuser",
502 account_kicker: "Compte",510 account_kicker: "Compte",
503- account_line_pat: "Membre de {} équipes. Les comptes sont créés en ligne de commande.",511+ account_line_pat: "Membre de {} équipes.",
504- account_line_single: "Les comptes sont créés en ligne de commande.",512+ account_line_single: "Membre d'une seule équipe.",
505 my_entries: "Mes saisies",513 my_entries: "Mes saisies",
506 my_entries_desc: "Consulter et corriger les temps que j'ai enregistrés.",514 my_entries_desc: "Consulter et corriger les temps que j'ai enregistrés.",
507 manage_categories_desc: "Ajouter, renommer ou archiver les catégories de l'équipe.",515 manage_categories_desc: "Ajouter, renommer ou archiver les catégories de l'équipe.",
@@ -569,6 +577,12 @@ pub static FR: Tr = Tr {
569 signup_team_hint: "Ton compte démarre avec une équipe à toi, à ton nom. Tu pourras la renommer, et quand on t'invitera dans une autre, tu pourras la quitter — vide, elle disparaîtra.",577 signup_team_hint: "Ton compte démarre avec une équipe à toi, à ton nom. Tu pourras la renommer, et quand on t'invitera dans une autre, tu pourras la quitter — vide, elle disparaîtra.",
570 err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.",578 err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.",
571 err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.",579 err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.",
580+ err_invite_expired: "Ce lien d'invitation a expiré ou n'est plus valable. Demandes-en un nouveau à la personne qui t'a invité.",
581+ invite_link: "Lien d'invitation",
582+ invite_link_hint: "Pour quelqu'un qui n'a pas encore de compte : le lien l'amène directement à l'inscription dans cette équipe. Il vaut une heure.",
583+ invite_link_generate: "Générer un lien",
584+ invite_link_ready: "Copie-le et envoie-le. Il expire dans une heure.",
585+ signup_joining_pat: "Tu rejoins l'équipe {}.",
572 err_password_mismatch: "La confirmation ne correspond pas.",586 err_password_mismatch: "La confirmation ne correspond pas.",
573 err_password_wrong: "Mot de passe actuel incorrect.",587 err_password_wrong: "Mot de passe actuel incorrect.",
574 invite_unknown_pat: "Aucun compte « {} ».",588 invite_unknown_pat: "Aucun compte « {} ».",
@@ -759,8 +773,8 @@ pub static EN: Tr = Tr {
759 accept: "Accept",773 accept: "Accept",
760 refuse: "Decline",774 refuse: "Decline",
761 account_kicker: "Account",775 account_kicker: "Account",
762- account_line_pat: "Member of {} teams. Accounts are created from the command line.",776+ account_line_pat: "Member of {} teams.",
763- account_line_single: "Accounts are created from the command line.",777+ account_line_single: "Member of a single team.",
764 my_entries: "My entries",778 my_entries: "My entries",
765 my_entries_desc: "View and fix the times I recorded.",779 my_entries_desc: "View and fix the times I recorded.",
766 manage_categories_desc: "Add, rename or archive the team's categories.",780 manage_categories_desc: "Add, rename or archive the team's categories.",
@@ -828,6 +842,12 @@ pub static EN: Tr = Tr {
828 signup_team_hint: "Your account starts with a team of your own, named after you. You can rename it, and once someone invites you to another one you can leave it — empty, it disappears.",842 signup_team_hint: "Your account starts with a team of your own, named after you. You can rename it, and once someone invites you to another one you can leave it — empty, it disappears.",
829 err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.",843 err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.",
830 err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.",844 err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.",
845+ err_invite_expired: "This invitation link has expired or is no longer valid. Ask whoever invited you for a new one.",
846+ invite_link: "Invitation link",
847+ invite_link_hint: "For someone who has no account yet: the link takes them straight to signup in this team. It lasts one hour.",
848+ invite_link_generate: "Generate a link",
849+ invite_link_ready: "Copy it and send it. It expires in an hour.",
850+ signup_joining_pat: "You are joining the {} team.",
831 err_password_mismatch: "The confirmation does not match.",851 err_password_mismatch: "The confirmation does not match.",
832 err_password_wrong: "Current password is incorrect.",852 err_password_wrong: "Current password is incorrect.",
833 invite_unknown_pat: "No account “{}”.",853 invite_unknown_pat: "No account “{}”.",
@@ -1018,8 +1038,8 @@ pub static DE: Tr = Tr {
1018 accept: "Annehmen",1038 accept: "Annehmen",
1019 refuse: "Ablehnen",1039 refuse: "Ablehnen",
1020 account_kicker: "Konto",1040 account_kicker: "Konto",
1021- account_line_pat: "Mitglied in {} Teams. Konten werden über die Kommandozeile angelegt.",1041+ account_line_pat: "Mitglied in {} Teams.",
1022- account_line_single: "Konten werden über die Kommandozeile angelegt.",1042+ account_line_single: "Mitglied in einem einzigen Team.",
1023 my_entries: "Meine Einträge",1043 my_entries: "Meine Einträge",
1024 my_entries_desc: "Erfasste Zeiten ansehen und korrigieren.",1044 my_entries_desc: "Erfasste Zeiten ansehen und korrigieren.",
1025 manage_categories_desc: "Kategorien des Teams hinzufügen, umbenennen oder archivieren.",1045 manage_categories_desc: "Kategorien des Teams hinzufügen, umbenennen oder archivieren.",
@@ -1087,6 +1107,12 @@ pub static DE: Tr = Tr {
1087 signup_team_hint: "Dein Konto startet mit einem eigenen Team unter deinem Namen. Du kannst es umbenennen, und sobald dich jemand in ein anderes einlädt, kannst du es verlassen — leer verschwindet es.",1107 signup_team_hint: "Dein Konto startet mit einem eigenen Team unter deinem Namen. Du kannst es umbenennen, und sobald dich jemand in ein anderes einlädt, kannst du es verlassen — leer verschwindet es.",
1088 err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.",1108 err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.",
1089 err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.",1109 err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.",
1110+ err_invite_expired: "Dieser Einladungslink ist abgelaufen oder nicht mehr gültig. Bitte um einen neuen.",
1111+ invite_link: "Einladungslink",
1112+ invite_link_hint: "Für jemanden ohne Konto: Der Link führt direkt zur Anmeldung in diesem Team. Er gilt eine Stunde.",
1113+ invite_link_generate: "Link erzeugen",
1114+ invite_link_ready: "Kopier ihn und schick ihn. Er läuft in einer Stunde ab.",
1115+ signup_joining_pat: "Du trittst dem Team {} bei.",
1090 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",1116 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",
1091 err_password_wrong: "Aktuelles Passwort ist falsch.",1117 err_password_wrong: "Aktuelles Passwort ist falsch.",
1092 invite_unknown_pat: "Kein Konto „{}“.",1118 invite_unknown_pat: "Kein Konto „{}“.",
@@ -1277,8 +1303,8 @@ pub static PT_BR: Tr = Tr {
1277 accept: "Aceitar",1303 accept: "Aceitar",
1278 refuse: "Recusar",1304 refuse: "Recusar",
1279 account_kicker: "Conta",1305 account_kicker: "Conta",
1280- account_line_pat: "Membro de {} equipes. As contas são criadas pela linha de comando.",1306+ account_line_pat: "Membro de {} equipes.",
1281- account_line_single: "As contas são criadas pela linha de comando.",1307+ account_line_single: "Membro de uma única equipe.",
1282 my_entries: "Meus registros",1308 my_entries: "Meus registros",
1283 my_entries_desc: "Ver e corrigir os tempos que eu registrei.",1309 my_entries_desc: "Ver e corrigir os tempos que eu registrei.",
1284 manage_categories_desc: "Adicionar, renomear ou arquivar as categorias da equipe.",1310 manage_categories_desc: "Adicionar, renomear ou arquivar as categorias da equipe.",
@@ -1346,6 +1372,12 @@ pub static PT_BR: Tr = Tr {
1346 signup_team_hint: "Sua conta começa com uma equipe sua, com o seu nome. Você pode renomeá-la e, quando alguém te convidar para outra, pode sair dela — vazia, ela desaparece.",1372 signup_team_hint: "Sua conta começa com uma equipe sua, com o seu nome. Você pode renomeá-la e, quando alguém te convidar para outra, pode sair dela — vazia, ela desaparece.",
1347 err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.",1373 err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.",
1348 err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.",1374 err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.",
1375+ err_invite_expired: "Este link de convite expirou ou não vale mais. Peça um novo a quem te convidou.",
1376+ invite_link: "Link de convite",
1377+ invite_link_hint: "Para quem ainda não tem conta: o link leva direto ao cadastro nesta equipe. Vale uma hora.",
1378+ invite_link_generate: "Gerar um link",
1379+ invite_link_ready: "Copie e envie. Expira em uma hora.",
1380+ signup_joining_pat: "Você está entrando na equipe {}.",
1349 err_password_mismatch: "A confirmação não confere.",1381 err_password_mismatch: "A confirmação não confere.",
1350 err_password_wrong: "Senha atual incorreta.",1382 err_password_wrong: "Senha atual incorreta.",
1351 invite_unknown_pat: "Nenhuma conta “{}”.",1383 invite_unknown_pat: "Nenhuma conta “{}”.",
modified src/routes/mod.rs +25 -0
@@ -76,6 +76,7 @@ pub fn router(state: AppState) -> Router {
7676 .route("/equipes/basculer", post(teams::team_switch))
7777 .route("/equipes/renommer", post(teams::team_rename))
7878 .route("/equipes/quitter", post(teams::team_leave))
79+ .route("/equipes/lien", post(teams::invite_link))
7980 .route("/equipes/inviter", post(teams::invite_create))
8081 .route("/invitations/{id}/accepter", post(teams::invite_accept))
8182 .route("/invitations/{id}/refuser", post(teams::invite_refuse))
@@ -258,6 +259,30 @@ pub async fn shell(
258259
259260 /// Encodage percent d'une valeur de query string (caractères non réservés
260261 /// RFC 3986 conservés) — partagé par les redirects et les liens de scope.
262+/// Origine publique de l'instance (`https://ex-ego.durillon.family`), déduite
263+/// des en-têtes du reverse proxy — nécessaire pour fabriquer un lien
264+/// d'invitation ABSOLU, qu'on enverra par message.
265+///
266+/// `X-Forwarded-Host` prime sur `Host` (Clever Cloud passe le domaine public
267+/// dans le premier), et le schéma vient de `X-Forwarded-Proto`, https par
268+/// défaut : l'application n'est jamais servie en clair ailleurs qu'en local.
269+pub(crate) fn public_origin(headers: &axum::http::HeaderMap) -> String {
270+ let host = headers
271+ .get("x-forwarded-host")
272+ .or_else(|| headers.get(axum::http::header::HOST))
273+ .and_then(|v| v.to_str().ok())
274+ .unwrap_or("localhost");
275+ let scheme = headers
276+ .get("x-forwarded-proto")
277+ .and_then(|v| v.to_str().ok())
278+ .unwrap_or(if host.starts_with("localhost") || host.starts_with("127.0.0.1") {
279+ "http"
280+ } else {
281+ "https"
282+ });
283+ format!("{scheme}://{host}")
284+}
285+
261286 pub(crate) fn urlenc(s: &str) -> String {
262287 let mut out = String::with_capacity(s.len());
263288 for b in s.bytes() {
@@ -76,6 +76,7 @@ pub fn router(state: AppState) -> Router {
76 .route("/equipes/basculer", post(teams::team_switch))76 .route("/equipes/basculer", post(teams::team_switch))
77 .route("/equipes/renommer", post(teams::team_rename))77 .route("/equipes/renommer", post(teams::team_rename))
78 .route("/equipes/quitter", post(teams::team_leave))78 .route("/equipes/quitter", post(teams::team_leave))
79+ .route("/equipes/lien", post(teams::invite_link))
79 .route("/equipes/inviter", post(teams::invite_create))80 .route("/equipes/inviter", post(teams::invite_create))
80 .route("/invitations/{id}/accepter", post(teams::invite_accept))81 .route("/invitations/{id}/accepter", post(teams::invite_accept))
81 .route("/invitations/{id}/refuser", post(teams::invite_refuse))82 .route("/invitations/{id}/refuser", post(teams::invite_refuse))
@@ -258,6 +259,30 @@ pub async fn shell(
258 259
259 /// Encodage percent d'une valeur de query string (caractères non réservés260 /// Encodage percent d'une valeur de query string (caractères non réservés
260 /// RFC 3986 conservés) — partagé par les redirects et les liens de scope.261 /// RFC 3986 conservés) — partagé par les redirects et les liens de scope.
262+/// Origine publique de l'instance (`https://ex-ego.durillon.family`), déduite
263+/// des en-têtes du reverse proxy — nécessaire pour fabriquer un lien
264+/// d'invitation ABSOLU, qu'on enverra par message.
265+///
266+/// `X-Forwarded-Host` prime sur `Host` (Clever Cloud passe le domaine public
267+/// dans le premier), et le schéma vient de `X-Forwarded-Proto`, https par
268+/// défaut : l'application n'est jamais servie en clair ailleurs qu'en local.
269+pub(crate) fn public_origin(headers: &axum::http::HeaderMap) -> String {
270+ let host = headers
271+ .get("x-forwarded-host")
272+ .or_else(|| headers.get(axum::http::header::HOST))
273+ .and_then(|v| v.to_str().ok())
274+ .unwrap_or("localhost");
275+ let scheme = headers
276+ .get("x-forwarded-proto")
277+ .and_then(|v| v.to_str().ok())
278+ .unwrap_or(if host.starts_with("localhost") || host.starts_with("127.0.0.1") {
279+ "http"
280+ } else {
281+ "https"
282+ });
283+ format!("{scheme}://{host}")
284+}
285+
261 pub(crate) fn urlenc(s: &str) -> String {286 pub(crate) fn urlenc(s: &str) -> String {
262 let mut out = String::with_capacity(s.len());287 let mut out = String::with_capacity(s.len());
263 for b in s.bytes() {288 for b in s.bytes() {
modified src/routes/settings.rs +5 -0
@@ -53,6 +53,8 @@ pub struct SettingsPage {
5353 pub leave_label: String,
5454 pub sent: Vec<SentView>,
5555 pub can_leave: bool,
56+ /// Lien d'invitation fraîchement forgé, à copier (absent le reste du temps).
57+ pub invite_link: Option<String>,
5658 }
5759
5860 #[derive(Deserialize)]
@@ -61,6 +63,8 @@ pub struct SettingsQuery {
6163 err: Option<String>,
6264 /// Username rejoué dans les erreurs d'invitation.
6365 nom: Option<String>,
66+ /// Lien d'invitation à afficher, au retour de /equipes/lien.
67+ lien: Option<String>,
6468 }
6569
6670 pub async fn settings_page(
@@ -183,6 +187,7 @@ pub async fn settings_page(
183187 invite_error,
184188 sent,
185189 can_leave,
190+ invite_link: q.lien.filter(|l| l.starts_with("http")),
186191 }))
187192 }
188193
@@ -53,6 +53,8 @@ pub struct SettingsPage {
53 pub leave_label: String,53 pub leave_label: String,
54 pub sent: Vec<SentView>,54 pub sent: Vec<SentView>,
55 pub can_leave: bool,55 pub can_leave: bool,
56+ /// Lien d'invitation fraîchement forgé, à copier (absent le reste du temps).
57+ pub invite_link: Option<String>,
56 }58 }
57 59
58 #[derive(Deserialize)]60 #[derive(Deserialize)]
@@ -61,6 +63,8 @@ pub struct SettingsQuery {
61 err: Option<String>,63 err: Option<String>,
62 /// Username rejoué dans les erreurs d'invitation.64 /// Username rejoué dans les erreurs d'invitation.
63 nom: Option<String>,65 nom: Option<String>,
66+ /// Lien d'invitation à afficher, au retour de /equipes/lien.
67+ lien: Option<String>,
64 }68 }
65 69
66 pub async fn settings_page(70 pub async fn settings_page(
@@ -183,6 +187,7 @@ pub async fn settings_page(
183 invite_error,187 invite_error,
184 sent,188 sent,
185 can_leave,189 can_leave,
190+ invite_link: q.lien.filter(|l| l.starts_with("http")),
186 }))191 }))
187 }192 }
188 193
modified src/routes/signup.rs +100 -22
@@ -17,8 +17,9 @@ use serde::Deserialize;
1717 use subtle::ConstantTimeEq;
1818
1919 use crate::auth::extractor::AuthUser;
20-use crate::auth::password;
21-use crate::db::users::{self, Signup};
20+use crate::auth::{invite, password};
21+use crate::db::teams;
22+use crate::db::users::{self, InitialTeam, Signup};
2223 use crate::error::AppError;
2324 use crate::i18n::Tr;
2425 use crate::ratelimit::client_ip;
@@ -36,6 +37,19 @@ pub struct SignupPage {
3637 pub error: Option<&'static str>,
3738 /// Identifiant rejoué dans le champ après une erreur.
3839 pub username: String,
40+ /// Arrivée par lien d'invitation : l'équipe d'accueil est déjà connue,
41+ /// le champ « clé d'inscription » disparaît (le biscuit fait autorité).
42+ pub invite: Option<InviteView>,
43+ /// « Tu rejoins l'équipe Maison. » — interpolé côté Rust (motif i18n).
44+ pub joining: String,
45+}
46+
47+pub struct InviteView {
48+ /// Jeton du formulaire (10 min), seul champ caché du POST : l'équipe
49+ /// d'accueil ne vit QUE là, signée. Pas de champ en clair à réécrire.
50+ pub token: String,
51+ /// Pour l'affichage seulement (« Tu rejoins l'équipe X »).
52+ pub team_name: String,
3953 }
4054
4155 #[derive(Deserialize)]
@@ -43,19 +57,31 @@ pub struct SignupQuery {
4357 err: Option<String>,
4458 /// Identifiant à rejouer (jamais le mot de passe ni la clé).
4559 nom: Option<String>,
60+ /// Jeton du lien partagé (1 h) — présent à l'atterrissage.
61+ invitation: Option<String>,
4662 }
4763
48-/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout
49-/// (ni page, ni indice de son existence).
64+/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, l'inscription **par clé** n'existe
65+/// pas du tout (ni page, ni indice de son existence). Les liens d'invitation,
66+/// eux, restent recevables : le biscuit porte sa propre autorisation, signé
67+/// par un membre de l'équipe — c'est plus fort qu'un secret partagé.
5068 fn key_or_404() -> Result<String, AppError> {
5169 crate::config::signup_key().ok_or(AppError::NotFound)
5270 }
5371
5472 pub async fn signup_page(
73+ State(state): State<AppState>,
5574 user: Option<AuthUser>,
5675 Query(q): Query<SignupQuery>,
5776 ) -> Result<Response, AppError> {
58- key_or_404()?;
77+ // Un lien d'invitation valide ouvre la page même sans SIGNUP_KEY.
78+ let invited = match q.invitation.as_deref() {
79+ Some(t) => Some(resolve_invite(&state, t).await?),
80+ None => None,
81+ };
82+ if invited.is_none() {
83+ key_or_404()?;
84+ }
5985 if user.is_some() {
6086 return Ok(Redirect::to("/").into_response());
6187 }
@@ -74,15 +100,42 @@ pub async fn signup_page(
74100 _ => None,
75101 },
76102 username: q.nom.unwrap_or_default(),
103+ joining: match &invited {
104+ Some(i) => crate::i18n::p1(tr.signup_joining_pat, &i.team_name),
105+ None => String::new(),
106+ },
107+ invite: invited,
77108 })
78109 .into_response())
79110 }
80111
112+/// Jeton d'invitation → équipe d'accueil + jeton court pour le formulaire.
113+/// Un jeton illisible, expiré ou pointant sur une équipe disparue est traité
114+/// comme « lien périmé » : ce sont les trois façons d'arriver trop tard.
115+async fn resolve_invite(state: &AppState, token: &str) -> Result<InviteView, AppError> {
116+ let team_id = invite::verify_link(token, state.public).map_err(|_| AppError::InviteExpired)?;
117+ let team = teams::find_by_id(&state.pool, team_id)
118+ .await?
119+ .ok_or(AppError::InviteExpired)?;
120+ let form_token =
121+ invite::mint_form(&state.root, team.id, &team.name).map_err(AppError::Internal)?;
122+ Ok(InviteView {
123+ token: form_token,
124+ team_name: team.name,
125+ })
126+}
127+
81128 #[derive(Deserialize)]
82129 pub struct SignupForm {
83130 username: String,
84131 password: String,
132+ /// Clé d'inscription — vide et ignorée sur le chemin « lien ».
133+ #[serde(default)]
85134 cle: String,
135+ /// Jeton court du formulaire (chemin « lien d'invitation ») — porte
136+ /// l'équipe d'accueil, signée.
137+ #[serde(default)]
138+ invitation: String,
86139 }
87140
88141 /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de
@@ -100,23 +153,38 @@ pub async fn signup_submit(
100153 jar: CookieJar,
101154 Form(form): Form<SignupForm>,
102155 ) -> Result<Response, AppError> {
103- let expected = key_or_404()?;
104156 let ip = client_ip(&headers, Some(peer));
157+ let invited = !form.invitation.is_empty();
105158
106- // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota se
107- // consulte AVANT la comparaison — au-delà, la clé n'est même plus testée,
108- // sinon la limite ne ralentirait rien.
109- if !state.bad_keys.allows(&ip) {
110- tracing::warn!(%ip, "inscription refusée : trop de clés fausses");
111- return Ok(back("essais", &form));
112- }
113- // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé
114- // fausse ne dit rien de l'existence d'un identifiant.
115- let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into();
116- if !ok {
117- state.bad_keys.record(&ip);
118- return Ok(back("cle", &form));
119- }
159+ // Deux portes, deux justificatifs. Le lien d'invitation se suffit à
160+ // lui-même : il est signé par la clé racine, à la demande d'un membre de
161+ // l'équipe visée. La clé partagée, elle, reste la porte « libre-service ».
162+ let team = if invited {
163+ // `verify_form` : un jeton de LIEN (fait `join_team`) est refusé ici.
164+ let team_id = invite::verify_form(&form.invitation, state.public)
165+ .map_err(|_| AppError::InviteExpired)?;
166+ if teams::find_by_id(&state.pool, team_id).await?.is_none() {
167+ return Err(AppError::InviteExpired);
168+ }
169+ InitialTeam::Existing(team_id)
170+ } else {
171+ let expected = key_or_404()?;
172+ // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota
173+ // se consulte AVANT la comparaison — au-delà, la clé n'est même plus
174+ // testée, sinon la limite ne ralentirait rien.
175+ if !state.bad_keys.allows(&ip) {
176+ tracing::warn!(%ip, "inscription refusée : trop de clés fausses");
177+ return Ok(back("essais", &form));
178+ }
179+ // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé
180+ // fausse ne dit rien de l'existence d'un identifiant.
181+ let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into();
182+ if !ok {
183+ state.bad_keys.record(&ip);
184+ return Ok(back("cle", &form));
185+ }
186+ InitialTeam::Neutral
187+ };
120188
121189 let username = form.username.trim().to_string();
122190 if !valid_username(&username) {
@@ -125,7 +193,10 @@ pub async fn signup_submit(
125193 if form.password.len() < 8 {
126194 return Ok(back("court", &form));
127195 }
196+
128197 // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit.
198+ // Il vaut pour les deux portes : un lien d'invitation ne doit pas non plus
199+ // servir à fabriquer des comptes en série.
129200 if !state.signups.allows(&ip) {
130201 tracing::warn!(%ip, "inscription refusée : quota horaire atteint");
131202 return Ok(back("trop", &form));
@@ -133,7 +204,7 @@ pub async fn signup_submit(
133204
134205 let hash = password::hash(&form.password).map_err(AppError::Internal)?;
135206 let lang = crate::config::default_lang();
136- match users::signup(&state.pool, &username, &hash, lang.code()).await? {
207+ match users::signup(&state.pool, &username, &hash, lang.code(), team).await? {
137208 Signup::UsernameTaken => Ok(back("pris", &form)),
138209 Signup::Created { user_id, team_id } => {
139210 state.signups.record(&ip);
@@ -150,8 +221,15 @@ pub async fn signup_submit(
150221 /// Retour au formulaire avec l'erreur et l'identifiant rejoué (jamais le mot
151222 /// de passe ni la clé).
152223 fn back(err: &str, form: &SignupForm) -> Response {
224+ // Le jeton du formulaire (10 min) repart dans l'URL : sans lui, corriger
225+ // une faute de frappe renverrait sur la page « clé » sans équipe d'accueil.
226+ let invitation = if form.invitation.is_empty() {
227+ String::new()
228+ } else {
229+ format!("&invitation={}", crate::routes::urlenc(&form.invitation))
230+ };
153231 Redirect::to(&format!(
154- "/inscription?err={err}&nom={}",
232+ "/inscription?err={err}&nom={}{invitation}",
155233 crate::routes::urlenc(form.username.trim())
156234 ))
157235 .into_response()
@@ -17,8 +17,9 @@ use serde::Deserialize;
17 use subtle::ConstantTimeEq;17 use subtle::ConstantTimeEq;
18 18
19 use crate::auth::extractor::AuthUser;19 use crate::auth::extractor::AuthUser;
20-use crate::auth::password;20+use crate::auth::{invite, password};
21-use crate::db::users::{self, Signup};21+use crate::db::teams;
22+use crate::db::users::{self, InitialTeam, Signup};
22 use crate::error::AppError;23 use crate::error::AppError;
23 use crate::i18n::Tr;24 use crate::i18n::Tr;
24 use crate::ratelimit::client_ip;25 use crate::ratelimit::client_ip;
@@ -36,6 +37,19 @@ pub struct SignupPage {
36 pub error: Option<&'static str>,37 pub error: Option<&'static str>,
37 /// Identifiant rejoué dans le champ après une erreur.38 /// Identifiant rejoué dans le champ après une erreur.
38 pub username: String,39 pub username: String,
40+ /// Arrivée par lien d'invitation : l'équipe d'accueil est déjà connue,
41+ /// le champ « clé d'inscription » disparaît (le biscuit fait autorité).
42+ pub invite: Option<InviteView>,
43+ /// « Tu rejoins l'équipe Maison. » — interpolé côté Rust (motif i18n).
44+ pub joining: String,
45+}
46+
47+pub struct InviteView {
48+ /// Jeton du formulaire (10 min), seul champ caché du POST : l'équipe
49+ /// d'accueil ne vit QUE là, signée. Pas de champ en clair à réécrire.
50+ pub token: String,
51+ /// Pour l'affichage seulement (« Tu rejoins l'équipe X »).
52+ pub team_name: String,
39 }53 }
40 54
41 #[derive(Deserialize)]55 #[derive(Deserialize)]
@@ -43,19 +57,31 @@ pub struct SignupQuery {
43 err: Option<String>,57 err: Option<String>,
44 /// Identifiant à rejouer (jamais le mot de passe ni la clé).58 /// Identifiant à rejouer (jamais le mot de passe ni la clé).
45 nom: Option<String>,59 nom: Option<String>,
60+ /// Jeton du lien partagé (1 h) — présent à l'atterrissage.
61+ invitation: Option<String>,
46 }62 }
47 63
48-/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout64+/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, l'inscription **par clé** n'existe
49-/// (ni page, ni indice de son existence).65+/// pas du tout (ni page, ni indice de son existence). Les liens d'invitation,
66+/// eux, restent recevables : le biscuit porte sa propre autorisation, signé
67+/// par un membre de l'équipe — c'est plus fort qu'un secret partagé.
50 fn key_or_404() -> Result<String, AppError> {68 fn key_or_404() -> Result<String, AppError> {
51 crate::config::signup_key().ok_or(AppError::NotFound)69 crate::config::signup_key().ok_or(AppError::NotFound)
52 }70 }
53 71
54 pub async fn signup_page(72 pub async fn signup_page(
73+ State(state): State<AppState>,
55 user: Option<AuthUser>,74 user: Option<AuthUser>,
56 Query(q): Query<SignupQuery>,75 Query(q): Query<SignupQuery>,
57 ) -> Result<Response, AppError> {76 ) -> Result<Response, AppError> {
58- key_or_404()?;77+ // Un lien d'invitation valide ouvre la page même sans SIGNUP_KEY.
78+ let invited = match q.invitation.as_deref() {
79+ Some(t) => Some(resolve_invite(&state, t).await?),
80+ None => None,
81+ };
82+ if invited.is_none() {
83+ key_or_404()?;
84+ }
59 if user.is_some() {85 if user.is_some() {
60 return Ok(Redirect::to("/").into_response());86 return Ok(Redirect::to("/").into_response());
61 }87 }
@@ -74,15 +100,42 @@ pub async fn signup_page(
74 _ => None,100 _ => None,
75 },101 },
76 username: q.nom.unwrap_or_default(),102 username: q.nom.unwrap_or_default(),
103+ joining: match &invited {
104+ Some(i) => crate::i18n::p1(tr.signup_joining_pat, &i.team_name),
105+ None => String::new(),
106+ },
107+ invite: invited,
77 })108 })
78 .into_response())109 .into_response())
79 }110 }
80 111
112+/// Jeton d'invitation → équipe d'accueil + jeton court pour le formulaire.
113+/// Un jeton illisible, expiré ou pointant sur une équipe disparue est traité
114+/// comme « lien périmé » : ce sont les trois façons d'arriver trop tard.
115+async fn resolve_invite(state: &AppState, token: &str) -> Result<InviteView, AppError> {
116+ let team_id = invite::verify_link(token, state.public).map_err(|_| AppError::InviteExpired)?;
117+ let team = teams::find_by_id(&state.pool, team_id)
118+ .await?
119+ .ok_or(AppError::InviteExpired)?;
120+ let form_token =
121+ invite::mint_form(&state.root, team.id, &team.name).map_err(AppError::Internal)?;
122+ Ok(InviteView {
123+ token: form_token,
124+ team_name: team.name,
125+ })
126+}
127+
81 #[derive(Deserialize)]128 #[derive(Deserialize)]
82 pub struct SignupForm {129 pub struct SignupForm {
83 username: String,130 username: String,
84 password: String,131 password: String,
132+ /// Clé d'inscription — vide et ignorée sur le chemin « lien ».
133+ #[serde(default)]
85 cle: String,134 cle: String,
135+ /// Jeton court du formulaire (chemin « lien d'invitation ») — porte
136+ /// l'équipe d'accueil, signée.
137+ #[serde(default)]
138+ invitation: String,
86 }139 }
87 140
88 /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de141 /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de
@@ -100,23 +153,38 @@ pub async fn signup_submit(
100 jar: CookieJar,153 jar: CookieJar,
101 Form(form): Form<SignupForm>,154 Form(form): Form<SignupForm>,
102 ) -> Result<Response, AppError> {155 ) -> Result<Response, AppError> {
103- let expected = key_or_404()?;
104 let ip = client_ip(&headers, Some(peer));156 let ip = client_ip(&headers, Some(peer));
157+ let invited = !form.invitation.is_empty();
105 158
106- // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota se159+ // Deux portes, deux justificatifs. Le lien d'invitation se suffit à
107- // consulte AVANT la comparaison — au-delà, la clé n'est même plus testée,160+ // lui-même : il est signé par la clé racine, à la demande d'un membre de
108- // sinon la limite ne ralentirait rien.161+ // l'équipe visée. La clé partagée, elle, reste la porte « libre-service ».
109- if !state.bad_keys.allows(&ip) {162+ let team = if invited {
110- tracing::warn!(%ip, "inscription refusée : trop de clés fausses");163+ // `verify_form` : un jeton de LIEN (fait `join_team`) est refusé ici.
111- return Ok(back("essais", &form));164+ let team_id = invite::verify_form(&form.invitation, state.public)
112- }165+ .map_err(|_| AppError::InviteExpired)?;
113- // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé166+ if teams::find_by_id(&state.pool, team_id).await?.is_none() {
114- // fausse ne dit rien de l'existence d'un identifiant.167+ return Err(AppError::InviteExpired);
115- let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into();168+ }
116- if !ok {169+ InitialTeam::Existing(team_id)
117- state.bad_keys.record(&ip);170+ } else {
118- return Ok(back("cle", &form));171+ let expected = key_or_404()?;
119- }172+ // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota
173+ // se consulte AVANT la comparaison — au-delà, la clé n'est même plus
174+ // testée, sinon la limite ne ralentirait rien.
175+ if !state.bad_keys.allows(&ip) {
176+ tracing::warn!(%ip, "inscription refusée : trop de clés fausses");
177+ return Ok(back("essais", &form));
178+ }
179+ // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé
180+ // fausse ne dit rien de l'existence d'un identifiant.
181+ let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into();
182+ if !ok {
183+ state.bad_keys.record(&ip);
184+ return Ok(back("cle", &form));
185+ }
186+ InitialTeam::Neutral
187+ };
120 188
121 let username = form.username.trim().to_string();189 let username = form.username.trim().to_string();
122 if !valid_username(&username) {190 if !valid_username(&username) {
@@ -125,7 +193,10 @@ pub async fn signup_submit(
125 if form.password.len() < 8 {193 if form.password.len() < 8 {
126 return Ok(back("court", &form));194 return Ok(back("court", &form));
127 }195 }
196+
128 // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit.197 // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit.
198+ // Il vaut pour les deux portes : un lien d'invitation ne doit pas non plus
199+ // servir à fabriquer des comptes en série.
129 if !state.signups.allows(&ip) {200 if !state.signups.allows(&ip) {
130 tracing::warn!(%ip, "inscription refusée : quota horaire atteint");201 tracing::warn!(%ip, "inscription refusée : quota horaire atteint");
131 return Ok(back("trop", &form));202 return Ok(back("trop", &form));
@@ -133,7 +204,7 @@ pub async fn signup_submit(
133 204
134 let hash = password::hash(&form.password).map_err(AppError::Internal)?;205 let hash = password::hash(&form.password).map_err(AppError::Internal)?;
135 let lang = crate::config::default_lang();206 let lang = crate::config::default_lang();
136- match users::signup(&state.pool, &username, &hash, lang.code()).await? {207+ match users::signup(&state.pool, &username, &hash, lang.code(), team).await? {
137 Signup::UsernameTaken => Ok(back("pris", &form)),208 Signup::UsernameTaken => Ok(back("pris", &form)),
138 Signup::Created { user_id, team_id } => {209 Signup::Created { user_id, team_id } => {
139 state.signups.record(&ip);210 state.signups.record(&ip);
@@ -150,8 +221,15 @@ pub async fn signup_submit(
150 /// Retour au formulaire avec l'erreur et l'identifiant rejoué (jamais le mot221 /// Retour au formulaire avec l'erreur et l'identifiant rejoué (jamais le mot
151 /// de passe ni la clé).222 /// de passe ni la clé).
152 fn back(err: &str, form: &SignupForm) -> Response {223 fn back(err: &str, form: &SignupForm) -> Response {
224+ // Le jeton du formulaire (10 min) repart dans l'URL : sans lui, corriger
225+ // une faute de frappe renverrait sur la page « clé » sans équipe d'accueil.
226+ let invitation = if form.invitation.is_empty() {
227+ String::new()
228+ } else {
229+ format!("&invitation={}", crate::routes::urlenc(&form.invitation))
230+ };
153 Redirect::to(&format!(231 Redirect::to(&format!(
154- "/inscription?err={err}&nom={}",232+ "/inscription?err={err}&nom={}{invitation}",
155 crate::routes::urlenc(form.username.trim())233 crate::routes::urlenc(form.username.trim())
156 ))234 ))
157 .into_response()235 .into_response()
modified src/routes/teams.rs +30 -0
@@ -1,10 +1,12 @@
11 use axum::Form;
22 use axum::extract::{Path, State};
3+use axum::http::HeaderMap;
34 use axum::response::{IntoResponse, Redirect, Response};
45 use axum_extra::extract::CookieJar;
56 use serde::Deserialize;
67 use uuid::Uuid;
78
9+use crate::auth::invite;
810 use crate::db::{invitations, teams, timers, users};
911 use crate::error::AppError;
1012 use crate::state::AppState;
@@ -136,6 +138,34 @@ pub async fn team_leave(
136138 .into_response())
137139 }
138140
141+/// POST /equipes/lien — forge un lien d'invitation vers l'équipe active.
142+///
143+/// N'importe quel membre peut le faire (pas de rôle admin dans ce projet), et
144+/// `ActiveTeam` garantit déjà l'appartenance. Le jeton vaut une heure ; celui
145+/// que le formulaire d'inscription posera ensuite ne vaudra que dix minutes.
146+///
147+/// Le lien repart en query string plutôt que dans la page : la convention du
148+/// projet est redirect + query params, et ça évite de re-poster en rechargeant.
149+pub async fn invite_link(
150+ State(state): State<AppState>,
151+ ctx: ActiveTeam,
152+ headers: HeaderMap,
153+ Form(form): Form<CsrfOnly>,
154+) -> Result<Redirect, AppError> {
155+ if !ctx.user.check_csrf(&state, &form.csrf) {
156+ return Err(AppError::Csrf);
157+ }
158+ let token =
159+ invite::mint_link(&state.root, ctx.team_id, &ctx.team_name).map_err(AppError::Internal)?;
160+ Ok(Redirect::to(&format!(
161+ "/parametres?lien={}",
162+ crate::routes::urlenc(&format!(
163+ "{}/inscription?invitation={token}",
164+ crate::routes::public_origin(&headers)
165+ ))
166+ )))
167+}
168+
139169 #[derive(Deserialize)]
140170 pub struct InviteForm {
141171 /// Pas nommé « username » : les gestionnaires de mots de passe prendraient
@@ -1,10 +1,12 @@
1 use axum::Form;1 use axum::Form;
2 use axum::extract::{Path, State};2 use axum::extract::{Path, State};
3+use axum::http::HeaderMap;
3 use axum::response::{IntoResponse, Redirect, Response};4 use axum::response::{IntoResponse, Redirect, Response};
4 use axum_extra::extract::CookieJar;5 use axum_extra::extract::CookieJar;
5 use serde::Deserialize;6 use serde::Deserialize;
6 use uuid::Uuid;7 use uuid::Uuid;
7 8
9+use crate::auth::invite;
8 use crate::db::{invitations, teams, timers, users};10 use crate::db::{invitations, teams, timers, users};
9 use crate::error::AppError;11 use crate::error::AppError;
10 use crate::state::AppState;12 use crate::state::AppState;
@@ -136,6 +138,34 @@ pub async fn team_leave(
136 .into_response())138 .into_response())
137 }139 }
138 140
141+/// POST /equipes/lien — forge un lien d'invitation vers l'équipe active.
142+///
143+/// N'importe quel membre peut le faire (pas de rôle admin dans ce projet), et
144+/// `ActiveTeam` garantit déjà l'appartenance. Le jeton vaut une heure ; celui
145+/// que le formulaire d'inscription posera ensuite ne vaudra que dix minutes.
146+///
147+/// Le lien repart en query string plutôt que dans la page : la convention du
148+/// projet est redirect + query params, et ça évite de re-poster en rechargeant.
149+pub async fn invite_link(
150+ State(state): State<AppState>,
151+ ctx: ActiveTeam,
152+ headers: HeaderMap,
153+ Form(form): Form<CsrfOnly>,
154+) -> Result<Redirect, AppError> {
155+ if !ctx.user.check_csrf(&state, &form.csrf) {
156+ return Err(AppError::Csrf);
157+ }
158+ let token =
159+ invite::mint_link(&state.root, ctx.team_id, &ctx.team_name).map_err(AppError::Internal)?;
160+ Ok(Redirect::to(&format!(
161+ "/parametres?lien={}",
162+ crate::routes::urlenc(&format!(
163+ "{}/inscription?invitation={token}",
164+ crate::routes::public_origin(&headers)
165+ ))
166+ )))
167+}
168+
139 #[derive(Deserialize)]169 #[derive(Deserialize)]
140 pub struct InviteForm {170 pub struct InviteForm {
141 /// Pas nommé « username » : les gestionnaires de mots de passe prendraient171 /// Pas nommé « username » : les gestionnaires de mots de passe prendraient
modified templates/settings.html +18 -0
@@ -74,6 +74,24 @@
7474 {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %}
7575 <p style="font-size:12px;opacity:.7;margin:0">{{ shell.tr.invite_note }}</p>
7676
77+<h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.invite_link }}</h2>
78+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.invite_link_hint }}</p>
79+{% if let Some(lien) = invite_link %}
80+<div class="field" style="margin-bottom:var(--space-2)">
81+ {# Le titre juste au-dessus nomme déjà le champ : aria-label plutôt qu'un
82+ <label> visible en doublon. readonly, pas disabled — le texte doit rester
83+ sélectionnable pour être copié. #}
84+ <input class="input" id="lien-invit" value="{{ lien }}" readonly
85+ aria-label="{{ shell.tr.invite_link }}" aria-describedby="lien-aide"
86+ style="min-height:44px;font-size:13px">
87+</div>
88+<p class="field-hint" id="lien-aide" style="margin-bottom:var(--space-2)">{{ shell.tr.invite_link_ready }}</p>
89+{% endif %}
90+<form method="post" action="/equipes/lien" style="margin-bottom:var(--space-4)">
91+ <input type="hidden" name="csrf" value="{{ csrf }}">
92+ <button type="submit" class="btn btn-secondary" style="min-height:44px">{{ shell.tr.invite_link_generate }}</button>
93+</form>
94+
7795 {% if !sent.is_empty() %}
7896 <h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.pending_invites }}</h2>
7997 {% for si in sent %}
@@ -74,6 +74,24 @@
74 {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %}74 {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %}
75 <p style="font-size:12px;opacity:.7;margin:0">{{ shell.tr.invite_note }}</p>75 <p style="font-size:12px;opacity:.7;margin:0">{{ shell.tr.invite_note }}</p>
76 76
77+<h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.invite_link }}</h2>
78+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.invite_link_hint }}</p>
79+{% if let Some(lien) = invite_link %}
80+<div class="field" style="margin-bottom:var(--space-2)">
81+ {# Le titre juste au-dessus nomme déjà le champ : aria-label plutôt qu'un
82+ <label> visible en doublon. readonly, pas disabled — le texte doit rester
83+ sélectionnable pour être copié. #}
84+ <input class="input" id="lien-invit" value="{{ lien }}" readonly
85+ aria-label="{{ shell.tr.invite_link }}" aria-describedby="lien-aide"
86+ style="min-height:44px;font-size:13px">
87+</div>
88+<p class="field-hint" id="lien-aide" style="margin-bottom:var(--space-2)">{{ shell.tr.invite_link_ready }}</p>
89+{% endif %}
90+<form method="post" action="/equipes/lien" style="margin-bottom:var(--space-4)">
91+ <input type="hidden" name="csrf" value="{{ csrf }}">
92+ <button type="submit" class="btn btn-secondary" style="min-height:44px">{{ shell.tr.invite_link_generate }}</button>
93+</form>
94+
77 {% if !sent.is_empty() %}95 {% if !sent.is_empty() %}
78 <h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.pending_invites }}</h2>96 <h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.pending_invites }}</h2>
79 {% for si in sent %}97 {% for si in sent %}
modified templates/signup.html +13 -0
@@ -18,8 +18,17 @@
1818 <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/>
1919 </svg>
2020 <h1>{{ tr.signup_title }}</h1>
21+ {% if let Some(inv) = invite %}
22+ <p class="tagline">{{ joining }}</p>
23+ {% else %}
2124 <p class="tagline">{{ tr.signup_intro }}</p>
25+ {% endif %}
2226 <form method="post" action="/inscription">
27+ {% if let Some(inv) = invite %}
28+ {# Seul porteur de l'équipe d'accueil : un biscuit team(<id>) de 10 min.
29+ Rien en clair à réécrire. #}
30+ <input type="hidden" name="invitation" value="{{ inv.token }}">
31+ {% endif %}
2332 <div class="field" style="margin-bottom:var(--space-3)">
2433 <label for="username" style="font-size:18px">{{ tr.username_label }}</label>
2534 <input class="input" id="username" name="username" value="{{ username }}"
@@ -31,6 +40,7 @@
3140 <input class="input" id="password" name="password" type="password"
3241 autocomplete="new-password" required minlength="8" style="font-size:18px">
3342 </div>
43+ {% if invite.is_none() %}
3444 <div class="field" style="margin-bottom:var(--space-2)">
3545 <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label>
3646 <input class="input" id="cle" name="cle" type="password" autocomplete="off"
@@ -38,11 +48,14 @@
3848 aria-describedby="cle-aide" style="font-size:18px">
3949 </div>
4050 <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p>
51+ {% endif %}
4152 {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
4253 <button type="submit" class="btn btn-primary btn-block"
4354 style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button>
4455 </form>
56+ {% if invite.is_none() %}
4557 <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p>
58+ {% endif %}
4659 <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a>
4760 </main>
4861 {% endblock %}
@@ -18,8 +18,17 @@
18 <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/>18 <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/>
19 </svg>19 </svg>
20 <h1>{{ tr.signup_title }}</h1>20 <h1>{{ tr.signup_title }}</h1>
21+ {% if let Some(inv) = invite %}
22+ <p class="tagline">{{ joining }}</p>
23+ {% else %}
21 <p class="tagline">{{ tr.signup_intro }}</p>24 <p class="tagline">{{ tr.signup_intro }}</p>
25+ {% endif %}
22 <form method="post" action="/inscription">26 <form method="post" action="/inscription">
27+ {% if let Some(inv) = invite %}
28+ {# Seul porteur de l'équipe d'accueil : un biscuit team(<id>) de 10 min.
29+ Rien en clair à réécrire. #}
30+ <input type="hidden" name="invitation" value="{{ inv.token }}">
31+ {% endif %}
23 <div class="field" style="margin-bottom:var(--space-3)">32 <div class="field" style="margin-bottom:var(--space-3)">
24 <label for="username" style="font-size:18px">{{ tr.username_label }}</label>33 <label for="username" style="font-size:18px">{{ tr.username_label }}</label>
25 <input class="input" id="username" name="username" value="{{ username }}"34 <input class="input" id="username" name="username" value="{{ username }}"
@@ -31,6 +40,7 @@
31 <input class="input" id="password" name="password" type="password"40 <input class="input" id="password" name="password" type="password"
32 autocomplete="new-password" required minlength="8" style="font-size:18px">41 autocomplete="new-password" required minlength="8" style="font-size:18px">
33 </div>42 </div>
43+ {% if invite.is_none() %}
34 <div class="field" style="margin-bottom:var(--space-2)">44 <div class="field" style="margin-bottom:var(--space-2)">
35 <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label>45 <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label>
36 <input class="input" id="cle" name="cle" type="password" autocomplete="off"46 <input class="input" id="cle" name="cle" type="password" autocomplete="off"
@@ -38,11 +48,14 @@
38 aria-describedby="cle-aide" style="font-size:18px">48 aria-describedby="cle-aide" style="font-size:18px">
39 </div>49 </div>
40 <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p>50 <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p>
51+ {% endif %}
41 {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}52 {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
42 <button type="submit" class="btn btn-primary btn-block"53 <button type="submit" class="btn btn-primary btn-block"
43 style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button>54 style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button>
44 </form>55 </form>
56+ {% if invite.is_none() %}
45 <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p>57 <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p>
58+ {% endif %}
46 <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a>59 <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a>
47 </main>60 </main>
48 {% endblock %}61 {% endblock %}