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.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 @@ | ||
| 1 | 1 | pub mod csrf; |
| 2 | 2 | pub mod extractor; |
| 3 | +pub mod invite; | |
| 3 | 4 | pub mod password; |
| 4 | 5 | pub mod revocation; |
| 5 | 6 | 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( | ||
| 164 | 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 | 167 | /// (id, nom) si l'utilisateur est membre de cette équipe — valide le cookie. |
| 177 | 168 | pub async fn membership( |
| 178 | 169 | 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 { | ||
| 86 | 86 | UsernameTaken, |
| 87 | 87 | } |
| 88 | 88 | |
| 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 | |
| 90 | 99 | /// transaction (invariant « jamais d'utilisateur sans équipe »). |
| 91 | 100 | /// |
| 92 | 101 | /// Le formulaire ne propose PAS de rejoindre une équipe existante : la clé |
| @@ -106,6 +115,7 @@ pub async fn signup( | ||
| 106 | 115 | username: &str, |
| 107 | 116 | password_hash: &str, |
| 108 | 117 | lang: &str, |
| 118 | + team: InitialTeam, | |
| 109 | 119 | ) -> sqlx::Result<Signup> { |
| 110 | 120 | let user_id = Uuid::now_v7(); |
| 111 | 121 | let mut tx = pool.begin().await?; |
| @@ -125,12 +135,20 @@ pub async fn signup( | ||
| 125 | 135 | return Ok(Signup::UsernameTaken); |
| 126 | 136 | } |
| 127 | 137 | |
| 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. | |
| 134 | 152 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") |
| 135 | 153 | .bind(team_id) |
| 136 | 154 | .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 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 | ||
| 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 { | ||
| 11 | 11 | NotFound, |
| 12 | 12 | #[error("formulaire invalide : {0}")] |
| 13 | 13 | BadRequest(String), |
| 14 | + #[error("lien d'invitation périmé")] | |
| 15 | + InviteExpired, | |
| 14 | 16 | #[error("jeton CSRF invalide")] |
| 15 | 17 | Csrf, |
| 16 | 18 | #[error("{0}")] |
| @@ -24,6 +26,9 @@ impl IntoResponse for AppError { | ||
| 24 | 26 | let (status, message) = match &self { |
| 25 | 27 | AppError::NotFound => (StatusCode::NOT_FOUND, tr.err_404.to_string()), |
| 26 | 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 | 32 | AppError::Csrf => (StatusCode::FORBIDDEN, tr.err_csrf.to_string()), |
| 28 | 33 | _ => { |
| 29 | 34 | 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 { | ||
| 280 | 280 | pub signup_team_hint: &'static str, |
| 281 | 281 | pub err_signup_rate_limited: &'static str, |
| 282 | 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 | 291 | pub err_password_mismatch: &'static str, |
| 284 | 292 | pub err_password_wrong: &'static str, |
| 285 | 293 | pub invite_unknown_pat: &'static str, |
| @@ -500,8 +508,8 @@ pub static FR: Tr = Tr { | ||
| 500 | 508 | accept: "Accepter", |
| 501 | 509 | refuse: "Refuser", |
| 502 | 510 | 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.", | |
| 505 | 513 | my_entries: "Mes saisies", |
| 506 | 514 | my_entries_desc: "Consulter et corriger les temps que j'ai enregistrés.", |
| 507 | 515 | manage_categories_desc: "Ajouter, renommer ou archiver les catégories de l'équipe.", |
| @@ -569,6 +577,12 @@ pub static FR: Tr = Tr { | ||
| 569 | 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 | 578 | err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.", |
| 571 | 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 | 586 | err_password_mismatch: "La confirmation ne correspond pas.", |
| 573 | 587 | err_password_wrong: "Mot de passe actuel incorrect.", |
| 574 | 588 | invite_unknown_pat: "Aucun compte « {} ».", |
| @@ -759,8 +773,8 @@ pub static EN: Tr = Tr { | ||
| 759 | 773 | accept: "Accept", |
| 760 | 774 | refuse: "Decline", |
| 761 | 775 | 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.", | |
| 764 | 778 | my_entries: "My entries", |
| 765 | 779 | my_entries_desc: "View and fix the times I recorded.", |
| 766 | 780 | manage_categories_desc: "Add, rename or archive the team's categories.", |
| @@ -828,6 +842,12 @@ pub static EN: Tr = Tr { | ||
| 828 | 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 | 843 | err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.", |
| 830 | 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 | 851 | err_password_mismatch: "The confirmation does not match.", |
| 832 | 852 | err_password_wrong: "Current password is incorrect.", |
| 833 | 853 | invite_unknown_pat: "No account “{}”.", |
| @@ -1018,8 +1038,8 @@ pub static DE: Tr = Tr { | ||
| 1018 | 1038 | accept: "Annehmen", |
| 1019 | 1039 | refuse: "Ablehnen", |
| 1020 | 1040 | 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.", | |
| 1023 | 1043 | my_entries: "Meine Einträge", |
| 1024 | 1044 | my_entries_desc: "Erfasste Zeiten ansehen und korrigieren.", |
| 1025 | 1045 | manage_categories_desc: "Kategorien des Teams hinzufügen, umbenennen oder archivieren.", |
| @@ -1087,6 +1107,12 @@ pub static DE: Tr = Tr { | ||
| 1087 | 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 | 1108 | err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.", |
| 1089 | 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 | 1116 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", |
| 1091 | 1117 | err_password_wrong: "Aktuelles Passwort ist falsch.", |
| 1092 | 1118 | invite_unknown_pat: "Kein Konto „{}“.", |
| @@ -1277,8 +1303,8 @@ pub static PT_BR: Tr = Tr { | ||
| 1277 | 1303 | accept: "Aceitar", |
| 1278 | 1304 | refuse: "Recusar", |
| 1279 | 1305 | 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.", | |
| 1282 | 1308 | my_entries: "Meus registros", |
| 1283 | 1309 | my_entries_desc: "Ver e corrigir os tempos que eu registrei.", |
| 1284 | 1310 | manage_categories_desc: "Adicionar, renomear ou arquivar as categorias da equipe.", |
| @@ -1346,6 +1372,12 @@ pub static PT_BR: Tr = Tr { | ||
| 1346 | 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 | 1373 | err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.", |
| 1348 | 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 | 1381 | err_password_mismatch: "A confirmação não confere.", |
| 1350 | 1382 | err_password_wrong: "Senha atual incorreta.", |
| 1351 | 1383 | 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 { | ||
| 76 | 76 | .route("/equipes/basculer", post(teams::team_switch)) |
| 77 | 77 | .route("/equipes/renommer", post(teams::team_rename)) |
| 78 | 78 | .route("/equipes/quitter", post(teams::team_leave)) |
| 79 | + .route("/equipes/lien", post(teams::invite_link)) | |
| 79 | 80 | .route("/equipes/inviter", post(teams::invite_create)) |
| 80 | 81 | .route("/invitations/{id}/accepter", post(teams::invite_accept)) |
| 81 | 82 | .route("/invitations/{id}/refuser", post(teams::invite_refuse)) |
| @@ -258,6 +259,30 @@ pub async fn shell( | ||
| 258 | 259 | |
| 259 | 260 | /// Encodage percent d'une valeur de query string (caractères non réservés |
| 260 | 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 | 286 | pub(crate) fn urlenc(s: &str) -> String { |
| 262 | 287 | let mut out = String::with_capacity(s.len()); |
| 263 | 288 | 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és | 260 | /// 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 { | ||
| 53 | 53 | pub leave_label: String, |
| 54 | 54 | pub sent: Vec<SentView>, |
| 55 | 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 | 60 | #[derive(Deserialize)] |
| @@ -61,6 +63,8 @@ pub struct SettingsQuery { | ||
| 61 | 63 | err: Option<String>, |
| 62 | 64 | /// Username rejoué dans les erreurs d'invitation. |
| 63 | 65 | nom: Option<String>, |
| 66 | + /// Lien d'invitation à afficher, au retour de /equipes/lien. | |
| 67 | + lien: Option<String>, | |
| 64 | 68 | } |
| 65 | 69 | |
| 66 | 70 | pub async fn settings_page( |
| @@ -183,6 +187,7 @@ pub async fn settings_page( | ||
| 183 | 187 | invite_error, |
| 184 | 188 | sent, |
| 185 | 189 | can_leave, |
| 190 | + invite_link: q.lien.filter(|l| l.starts_with("http")), | |
| 186 | 191 | })) |
| 187 | 192 | } |
| 188 | 193 | |
| @@ -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; | ||
| 17 | 17 | use subtle::ConstantTimeEq; |
| 18 | 18 | |
| 19 | 19 | 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}; | |
| 22 | 23 | use crate::error::AppError; |
| 23 | 24 | use crate::i18n::Tr; |
| 24 | 25 | use crate::ratelimit::client_ip; |
| @@ -36,6 +37,19 @@ pub struct SignupPage { | ||
| 36 | 37 | pub error: Option<&'static str>, |
| 37 | 38 | /// Identifiant rejoué dans le champ après une erreur. |
| 38 | 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 | 55 | #[derive(Deserialize)] |
| @@ -43,19 +57,31 @@ pub struct SignupQuery { | ||
| 43 | 57 | err: Option<String>, |
| 44 | 58 | /// Identifiant à rejouer (jamais le mot de passe ni la clé). |
| 45 | 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 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é. | |
| 50 | 68 | fn key_or_404() -> Result<String, AppError> { |
| 51 | 69 | crate::config::signup_key().ok_or(AppError::NotFound) |
| 52 | 70 | } |
| 53 | 71 | |
| 54 | 72 | pub async fn signup_page( |
| 73 | + State(state): State<AppState>, | |
| 55 | 74 | user: Option<AuthUser>, |
| 56 | 75 | Query(q): Query<SignupQuery>, |
| 57 | 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 | 85 | if user.is_some() { |
| 60 | 86 | return Ok(Redirect::to("/").into_response()); |
| 61 | 87 | } |
| @@ -74,15 +100,42 @@ pub async fn signup_page( | ||
| 74 | 100 | _ => None, |
| 75 | 101 | }, |
| 76 | 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 | 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 | 128 | #[derive(Deserialize)] |
| 82 | 129 | pub struct SignupForm { |
| 83 | 130 | username: String, |
| 84 | 131 | password: String, |
| 132 | + /// Clé d'inscription — vide et ignorée sur le chemin « lien ». | |
| 133 | + #[serde(default)] | |
| 85 | 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 | 141 | /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de |
| @@ -100,23 +153,38 @@ pub async fn signup_submit( | ||
| 100 | 153 | jar: CookieJar, |
| 101 | 154 | Form(form): Form<SignupForm>, |
| 102 | 155 | ) -> Result<Response, AppError> { |
| 103 | - let expected = key_or_404()?; | |
| 104 | 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 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 | + }; | |
| 120 | 188 | |
| 121 | 189 | let username = form.username.trim().to_string(); |
| 122 | 190 | if !valid_username(&username) { |
| @@ -125,7 +193,10 @@ pub async fn signup_submit( | ||
| 125 | 193 | if form.password.len() < 8 { |
| 126 | 194 | return Ok(back("court", &form)); |
| 127 | 195 | } |
| 196 | + | |
| 128 | 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 | 200 | if !state.signups.allows(&ip) { |
| 130 | 201 | tracing::warn!(%ip, "inscription refusée : quota horaire atteint"); |
| 131 | 202 | return Ok(back("trop", &form)); |
| @@ -133,7 +204,7 @@ pub async fn signup_submit( | ||
| 133 | 204 | |
| 134 | 205 | let hash = password::hash(&form.password).map_err(AppError::Internal)?; |
| 135 | 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 | 208 | Signup::UsernameTaken => Ok(back("pris", &form)), |
| 138 | 209 | Signup::Created { user_id, team_id } => { |
| 139 | 210 | state.signups.record(&ip); |
| @@ -150,8 +221,15 @@ pub async fn signup_submit( | ||
| 150 | 221 | /// Retour au formulaire avec l'erreur et l'identifiant rejoué (jamais le mot |
| 151 | 222 | /// de passe ni la clé). |
| 152 | 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 | 231 | Redirect::to(&format!( |
| 154 | - "/inscription?err={err}&nom={}", | |
| 232 | + "/inscription?err={err}&nom={}{invitation}", | |
| 155 | 233 | crate::routes::urlenc(form.username.trim()) |
| 156 | 234 | )) |
| 157 | 235 | .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 tout | 64 | +/// 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 de | 141 | /// 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 se | 159 | + // 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 mot | 221 | /// 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 @@ | ||
| 1 | 1 | use axum::Form; |
| 2 | 2 | use axum::extract::{Path, State}; |
| 3 | +use axum::http::HeaderMap; | |
| 3 | 4 | use axum::response::{IntoResponse, Redirect, Response}; |
| 4 | 5 | use axum_extra::extract::CookieJar; |
| 5 | 6 | use serde::Deserialize; |
| 6 | 7 | use uuid::Uuid; |
| 7 | 8 | |
| 9 | +use crate::auth::invite; | |
| 8 | 10 | use crate::db::{invitations, teams, timers, users}; |
| 9 | 11 | use crate::error::AppError; |
| 10 | 12 | use crate::state::AppState; |
| @@ -136,6 +138,34 @@ pub async fn team_leave( | ||
| 136 | 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 | 169 | #[derive(Deserialize)] |
| 140 | 170 | pub struct InviteForm { |
| 141 | 171 | /// 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 prendraient | 171 | /// Pas nommé « username » : les gestionnaires de mots de passe prendraient |
modified
templates/settings.html +18 -0 | @@ -74,6 +74,24 @@ | ||
| 74 | 74 | {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %} |
| 75 | 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 | 95 | {% if !sent.is_empty() %} |
| 78 | 96 | <h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.pending_invites }}</h2> |
| 79 | 97 | {% 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 @@ | ||
| 18 | 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 | 19 | </svg> |
| 20 | 20 | <h1>{{ tr.signup_title }}</h1> |
| 21 | + {% if let Some(inv) = invite %} | |
| 22 | + <p class="tagline">{{ joining }}</p> | |
| 23 | + {% else %} | |
| 21 | 24 | <p class="tagline">{{ tr.signup_intro }}</p> |
| 25 | + {% endif %} | |
| 22 | 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 | 32 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 24 | 33 | <label for="username" style="font-size:18px">{{ tr.username_label }}</label> |
| 25 | 34 | <input class="input" id="username" name="username" value="{{ username }}" |
| @@ -31,6 +40,7 @@ | ||
| 31 | 40 | <input class="input" id="password" name="password" type="password" |
| 32 | 41 | autocomplete="new-password" required minlength="8" style="font-size:18px"> |
| 33 | 42 | </div> |
| 43 | + {% if invite.is_none() %} | |
| 34 | 44 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 35 | 45 | <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label> |
| 36 | 46 | <input class="input" id="cle" name="cle" type="password" autocomplete="off" |
| @@ -38,11 +48,14 @@ | ||
| 38 | 48 | aria-describedby="cle-aide" style="font-size:18px"> |
| 39 | 49 | </div> |
| 40 | 50 | <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p> |
| 51 | + {% endif %} | |
| 41 | 52 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} |
| 42 | 53 | <button type="submit" class="btn btn-primary btn-block" |
| 43 | 54 | style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button> |
| 44 | 55 | </form> |
| 56 | + {% if invite.is_none() %} | |
| 45 | 57 | <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p> |
| 58 | + {% endif %} | |
| 46 | 59 | <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a> |
| 47 | 60 | </main> |
| 48 | 61 | {% 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 %} |