fix(inscription): équipe neutre à l'inscription, supprimée quand on la quitteUnverified
Le champ « équipe initiale » laissait un porteur de la clé rejoindre n'importe quelle équipe dont il devinait le nom, et donc en lire les temps, les catégories et les tâches. Il disparaît : l'inscription crée une équipe neutre à l'utilisateur, portant son nom. On rejoint les autres sur invitation. Au départ du dernier membre, teams::delete_if_empty supprime l'équipe si elle ne porte plus rien — ni catégorie ni timer — et emmène ses invitations en attente. C'est ce qui fait disparaître l'équipe neutre une fois son propriétaire parti ailleurs. Le garde-fou est dans le SQL : une équipe qui porte des catégories garde son historique, même vidée de ses membres. Le départ passe entièrement en transaction (todos, retrait du membre, suppression) au lieu de trois requêtes indépendantes.
906353d parent: a2acfa7 modified
src/db/teams.rs +46 -0 | @@ -118,6 +118,52 @@ pub async fn resolve(pool: &PgPool, input: &str) -> sqlx::Result<Resolved> { | ||
| 118 | 118 | }) |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | +/// Retire le membre DANS une transaction — le départ, la reprise des todos et | |
| 122 | +/// l'éventuelle suppression de l'équipe doivent réussir ou échouer ensemble. | |
| 123 | +pub async fn remove_member_tx( | |
| 124 | + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, | |
| 125 | + team_id: Uuid, | |
| 126 | + user_id: Uuid, | |
| 127 | +) -> sqlx::Result<u64> { | |
| 128 | + let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") | |
| 129 | + .bind(team_id) | |
| 130 | + .bind(user_id) | |
| 131 | + .execute(&mut **tx) | |
| 132 | + .await?; | |
| 133 | + Ok(res.rows_affected()) | |
| 134 | +} | |
| 135 | + | |
| 136 | +/// Supprime l'équipe si le départ l'a laissée VIDE DE TOUT : plus de membre, | |
| 137 | +/// aucune catégorie, aucun timer. C'est ce qui fait disparaître l'équipe | |
| 138 | +/// neutre d'une inscription une fois son propriétaire parti ailleurs. | |
| 139 | +/// | |
| 140 | +/// Le garde-fou est dans le SQL, pas en Rust : une équipe qui porte encore des | |
| 141 | +/// catégories garde ses temps et son historique, on ne la touche pas — même | |
| 142 | +/// vidée de ses membres (les ex-membres restent visibles dans les stats). | |
| 143 | +/// Les invitations en attente, elles, n'ont plus d'objet et partent avec. | |
| 144 | +/// | |
| 145 | +/// Rend `true` si l'équipe a bien été supprimée. | |
| 146 | +pub async fn delete_if_empty( | |
| 147 | + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, | |
| 148 | + team_id: Uuid, | |
| 149 | +) -> sqlx::Result<bool> { | |
| 150 | + const EMPTY: &str = "NOT EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = $1) | |
| 151 | + AND NOT EXISTS (SELECT 1 FROM categories c WHERE c.team_id = $1) | |
| 152 | + AND NOT EXISTS (SELECT 1 FROM timers t WHERE t.team_id = $1)"; | |
| 153 | + sqlx::query(&format!("DELETE FROM invitations WHERE team_id = $1 AND {EMPTY}")) | |
| 154 | + .bind(team_id) | |
| 155 | + .execute(&mut **tx) | |
| 156 | + .await?; | |
| 157 | + let res = sqlx::query(&format!( | |
| 158 | + "DELETE FROM teams WHERE id = $1 AND {EMPTY} | |
| 159 | + AND NOT EXISTS (SELECT 1 FROM invitations i WHERE i.team_id = $1)" | |
| 160 | + )) | |
| 161 | + .bind(team_id) | |
| 162 | + .execute(&mut **tx) | |
| 163 | + .await?; | |
| 164 | + Ok(res.rows_affected() > 0) | |
| 165 | +} | |
| 166 | + | |
| 121 | 167 | pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { |
| 122 | 168 | let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") |
| 123 | 169 | .bind(team_id) |
| @@ -118,6 +118,52 @@ pub async fn resolve(pool: &PgPool, input: &str) -> sqlx::Result<Resolved> { | |||
| 118 | }) | 118 | }) |
| 119 | } | 119 | } |
| 120 | 120 | ||
| 121 | +/// Retire le membre DANS une transaction — le départ, la reprise des todos et | ||
| 122 | +/// l'éventuelle suppression de l'équipe doivent réussir ou échouer ensemble. | ||
| 123 | +pub async fn remove_member_tx( | ||
| 124 | + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, | ||
| 125 | + team_id: Uuid, | ||
| 126 | + user_id: Uuid, | ||
| 127 | +) -> sqlx::Result<u64> { | ||
| 128 | + let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") | ||
| 129 | + .bind(team_id) | ||
| 130 | + .bind(user_id) | ||
| 131 | + .execute(&mut **tx) | ||
| 132 | + .await?; | ||
| 133 | + Ok(res.rows_affected()) | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +/// Supprime l'équipe si le départ l'a laissée VIDE DE TOUT : plus de membre, | ||
| 137 | +/// aucune catégorie, aucun timer. C'est ce qui fait disparaître l'équipe | ||
| 138 | +/// neutre d'une inscription une fois son propriétaire parti ailleurs. | ||
| 139 | +/// | ||
| 140 | +/// Le garde-fou est dans le SQL, pas en Rust : une équipe qui porte encore des | ||
| 141 | +/// catégories garde ses temps et son historique, on ne la touche pas — même | ||
| 142 | +/// vidée de ses membres (les ex-membres restent visibles dans les stats). | ||
| 143 | +/// Les invitations en attente, elles, n'ont plus d'objet et partent avec. | ||
| 144 | +/// | ||
| 145 | +/// Rend `true` si l'équipe a bien été supprimée. | ||
| 146 | +pub async fn delete_if_empty( | ||
| 147 | + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, | ||
| 148 | + team_id: Uuid, | ||
| 149 | +) -> sqlx::Result<bool> { | ||
| 150 | + const EMPTY: &str = "NOT EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = $1) | ||
| 151 | + AND NOT EXISTS (SELECT 1 FROM categories c WHERE c.team_id = $1) | ||
| 152 | + AND NOT EXISTS (SELECT 1 FROM timers t WHERE t.team_id = $1)"; | ||
| 153 | + sqlx::query(&format!("DELETE FROM invitations WHERE team_id = $1 AND {EMPTY}")) | ||
| 154 | + .bind(team_id) | ||
| 155 | + .execute(&mut **tx) | ||
| 156 | + .await?; | ||
| 157 | + let res = sqlx::query(&format!( | ||
| 158 | + "DELETE FROM teams WHERE id = $1 AND {EMPTY} | ||
| 159 | + AND NOT EXISTS (SELECT 1 FROM invitations i WHERE i.team_id = $1)" | ||
| 160 | + )) | ||
| 161 | + .bind(team_id) | ||
| 162 | + .execute(&mut **tx) | ||
| 163 | + .await?; | ||
| 164 | + Ok(res.rows_affected() > 0) | ||
| 165 | +} | ||
| 166 | + | ||
| 121 | pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { | 167 | pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { |
| 122 | let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") | 168 | let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") |
| 123 | .bind(team_id) | 169 | .bind(team_id) |
modified
src/db/users.rs +18 -27 | @@ -86,18 +86,19 @@ pub enum Signup { | ||
| 86 | 86 | UsernameTaken, |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | -/// Équipe d'accueil du nouveau compte, déjà résolue par l'appelant. | |
| 90 | -pub enum InitialTeam { | |
| 91 | - /// Une équipe existante, désignée par nom unique ou par id. | |
| 92 | - Existing(Uuid), | |
| 93 | - /// Nom encore libre : on la crée au passage (les homonymes sont permis | |
| 94 | - /// depuis la 0012, donc cet INSERT ne peut plus entrer en conflit). | |
| 95 | - Create(String), | |
| 96 | -} | |
| 97 | - | |
| 98 | -/// Inscription libre-service : le compte ET son rattachement d'équipe, en une | |
| 89 | +/// Inscription libre-service : le compte ET son **équipe neutre**, en une | |
| 99 | 90 | /// transaction (invariant « jamais d'utilisateur sans équipe »). |
| 100 | 91 | /// |
| 92 | +/// Le formulaire ne propose PAS de rejoindre une équipe existante : la clé | |
| 93 | +/// d'inscription ouvrirait alors les données de n'importe quelle équipe dont | |
| 94 | +/// on devine le nom. Le nouveau venu atterrit donc chez lui, et rejoint le | |
| 95 | +/// reste du monde sur invitation — il peut ensuite quitter cette équipe, qui | |
| 96 | +/// disparaît si elle est restée vide (cf. `teams::delete_if_empty`). | |
| 97 | +/// | |
| 98 | +/// Le nom de l'équipe est celui de l'utilisateur : neutre, indépendant de la | |
| 99 | +/// langue, et renommable dans Réglages. Les homonymes sont permis depuis la | |
| 100 | +/// 0012, donc cet INSERT ne peut pas entrer en conflit. | |
| 101 | +/// | |
| 101 | 102 | /// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend |
| 102 | 103 | /// une ligne vide au lieu d'avorter la transaction. |
| 103 | 104 | pub async fn signup( |
| @@ -105,7 +106,6 @@ pub async fn signup( | ||
| 105 | 106 | username: &str, |
| 106 | 107 | password_hash: &str, |
| 107 | 108 | lang: &str, |
| 108 | - team: InitialTeam, | |
| 109 | 109 | ) -> sqlx::Result<Signup> { |
| 110 | 110 | let user_id = Uuid::now_v7(); |
| 111 | 111 | let mut tx = pool.begin().await?; |
| @@ -125,21 +125,12 @@ pub async fn signup( | ||
| 125 | 125 | return Ok(Signup::UsernameTaken); |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | - let team_id = match team { | |
| 129 | - InitialTeam::Existing(id) => id, | |
| 130 | - InitialTeam::Create(name) => { | |
| 131 | - let id = Uuid::now_v7(); | |
| 132 | - sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)") | |
| 133 | - .bind(id) | |
| 134 | - .bind(&name) | |
| 135 | - .execute(&mut *tx) | |
| 136 | - .await?; | |
| 137 | - id | |
| 138 | - } | |
| 139 | - }; | |
| 140 | - | |
| 141 | - // Équipe supprimée entre la résolution et ici : la clé étrangère fait | |
| 142 | - // retomber toute la transaction, aucun compte orphelin ne survit. | |
| 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?; | |
| 143 | 134 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") |
| 144 | 135 | .bind(team_id) |
| 145 | 136 | .bind(user_id) |
| @@ -149,7 +140,7 @@ pub async fn signup( | ||
| 149 | 140 | Ok(Signup::Created { user_id, team_id }) |
| 150 | 141 | } |
| 151 | 142 | |
| 152 | -/// Cible d'une invitation./// Cible d'une invitation. | |
| 143 | +/// Cible d'une invitation./// Cible d'une invitation./// Cible d'une invitation. | |
| 153 | 144 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 154 | 145 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
| 155 | 146 | .bind(username) |
| @@ -86,18 +86,19 @@ pub enum Signup { | |||
| 86 | UsernameTaken, | 86 | UsernameTaken, |
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | -/// Équipe d'accueil du nouveau compte, déjà résolue par l'appelant. | 89 | +/// Inscription libre-service : le compte ET son **équipe neutre**, en une |
| 90 | -pub enum InitialTeam { | ||
| 91 | - /// Une équipe existante, désignée par nom unique ou par id. | ||
| 92 | - Existing(Uuid), | ||
| 93 | - /// Nom encore libre : on la crée au passage (les homonymes sont permis | ||
| 94 | - /// depuis la 0012, donc cet INSERT ne peut plus entrer en conflit). | ||
| 95 | - Create(String), | ||
| 96 | -} | ||
| 97 | - | ||
| 98 | -/// Inscription libre-service : le compte ET son rattachement d'équipe, en une | ||
| 99 | /// transaction (invariant « jamais d'utilisateur sans équipe »). | 90 | /// transaction (invariant « jamais d'utilisateur sans équipe »). |
| 100 | /// | 91 | /// |
| 92 | +/// Le formulaire ne propose PAS de rejoindre une équipe existante : la clé | ||
| 93 | +/// d'inscription ouvrirait alors les données de n'importe quelle équipe dont | ||
| 94 | +/// on devine le nom. Le nouveau venu atterrit donc chez lui, et rejoint le | ||
| 95 | +/// reste du monde sur invitation — il peut ensuite quitter cette équipe, qui | ||
| 96 | +/// disparaît si elle est restée vide (cf. `teams::delete_if_empty`). | ||
| 97 | +/// | ||
| 98 | +/// Le nom de l'équipe est celui de l'utilisateur : neutre, indépendant de la | ||
| 99 | +/// langue, et renommable dans Réglages. Les homonymes sont permis depuis la | ||
| 100 | +/// 0012, donc cet INSERT ne peut pas entrer en conflit. | ||
| 101 | +/// | ||
| 101 | /// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend | 102 | /// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend |
| 102 | /// une ligne vide au lieu d'avorter la transaction. | 103 | /// une ligne vide au lieu d'avorter la transaction. |
| 103 | pub async fn signup( | 104 | pub async fn signup( |
| @@ -105,7 +106,6 @@ pub async fn signup( | |||
| 105 | username: &str, | 106 | username: &str, |
| 106 | password_hash: &str, | 107 | password_hash: &str, |
| 107 | lang: &str, | 108 | lang: &str, |
| 108 | - team: InitialTeam, | ||
| 109 | ) -> sqlx::Result<Signup> { | 109 | ) -> sqlx::Result<Signup> { |
| 110 | let user_id = Uuid::now_v7(); | 110 | let user_id = Uuid::now_v7(); |
| 111 | let mut tx = pool.begin().await?; | 111 | let mut tx = pool.begin().await?; |
| @@ -125,21 +125,12 @@ pub async fn signup( | |||
| 125 | return Ok(Signup::UsernameTaken); | 125 | return Ok(Signup::UsernameTaken); |
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | - let team_id = match team { | 128 | + let team_id = Uuid::now_v7(); |
| 129 | - InitialTeam::Existing(id) => id, | 129 | + sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)") |
| 130 | - InitialTeam::Create(name) => { | 130 | + .bind(team_id) |
| 131 | - let id = Uuid::now_v7(); | 131 | + .bind(username) |
| 132 | - sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)") | 132 | + .execute(&mut *tx) |
| 133 | - .bind(id) | 133 | + .await?; |
| 134 | - .bind(&name) | ||
| 135 | - .execute(&mut *tx) | ||
| 136 | - .await?; | ||
| 137 | - id | ||
| 138 | - } | ||
| 139 | - }; | ||
| 140 | - | ||
| 141 | - // Équipe supprimée entre la résolution et ici : la clé étrangère fait | ||
| 142 | - // retomber toute la transaction, aucun compte orphelin ne survit. | ||
| 143 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") | 134 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") |
| 144 | .bind(team_id) | 135 | .bind(team_id) |
| 145 | .bind(user_id) | 136 | .bind(user_id) |
| @@ -149,7 +140,7 @@ pub async fn signup( | |||
| 149 | Ok(Signup::Created { user_id, team_id }) | 140 | Ok(Signup::Created { user_id, team_id }) |
| 150 | } | 141 | } |
| 151 | 142 | ||
| 152 | -/// Cible d'une invitation./// Cible d'une invitation. | 143 | +/// Cible d'une invitation./// Cible d'une invitation./// Cible d'une invitation. |
| 153 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { | 144 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 154 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") | 145 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
| 155 | .bind(username) | 146 | .bind(username) |
modified
src/i18n.rs +14 -28 | @@ -261,6 +261,8 @@ pub struct Tr { | ||
| 261 | 261 | pub toast_team_renamed: &'static str, |
| 262 | 262 | pub toast_joined_pat: &'static str, |
| 263 | 263 | pub toast_team_left: &'static str, |
| 264 | + /// L'équipe quittée était vide de tout : elle a disparu avec le départ. | |
| 265 | + pub toast_team_left_deleted: &'static str, | |
| 264 | 266 | pub err_team_name_empty: &'static str, |
| 265 | 267 | pub err_last_team: &'static str, |
| 266 | 268 | pub err_password_short: &'static str, |
| @@ -275,11 +277,7 @@ pub struct Tr { | ||
| 275 | 277 | pub err_signup_key: &'static str, |
| 276 | 278 | pub err_signup_username_taken: &'static str, |
| 277 | 279 | pub err_signup_username_invalid: &'static str, |
| 278 | - pub signup_team_label: &'static str, | |
| 279 | 280 | pub signup_team_hint: &'static str, |
| 280 | - pub err_signup_team_empty: &'static str, | |
| 281 | - pub err_signup_team_ambiguous: &'static str, | |
| 282 | - pub err_signup_team_unknown: &'static str, | |
| 283 | 281 | pub err_signup_rate_limited: &'static str, |
| 284 | 282 | pub err_signup_too_many_attempts: &'static str, |
| 285 | 283 | pub err_password_mismatch: &'static str, |
| @@ -554,11 +552,12 @@ pub static FR: Tr = Tr { | ||
| 554 | 552 | toast_team_renamed: "Équipe renommée", |
| 555 | 553 | toast_joined_pat: "Tu as rejoint {}", |
| 556 | 554 | toast_team_left: "Équipe quittée", |
| 555 | + toast_team_left_deleted: "Équipe quittée — elle était vide, elle a été supprimée", | |
| 557 | 556 | err_team_name_empty: "Le nom d'équipe ne peut pas être vide", |
| 558 | 557 | err_last_team: "Impossible de quitter ta dernière équipe", |
| 559 | 558 | err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", |
| 560 | 559 | signup_title: "Créer un compte", |
| 561 | - signup_intro: "Choisis un identifiant et un mot de passe, indique ton équipe, et saisis la clé d'inscription.", | |
| 560 | + signup_intro: "Choisis un identifiant et un mot de passe, et saisis la clé d'inscription.", | |
| 562 | 561 | signup_key_label: "Clé d'inscription", |
| 563 | 562 | signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.", |
| 564 | 563 | signup_submit: "Créer le compte", |
| @@ -567,11 +566,7 @@ pub static FR: Tr = Tr { | ||
| 567 | 566 | err_signup_key: "Clé d'inscription invalide.", |
| 568 | 567 | err_signup_username_taken: "Cet identifiant est déjà pris.", |
| 569 | 568 | err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.", |
| 570 | - signup_team_label: "Équipe initiale", | |
| 571 | - signup_team_hint: "Le nom d'une équipe existante pour la rejoindre, ou un nouveau nom pour créer la tienne. Si plusieurs équipes portent le même nom, colle plutôt son identifiant.", | |
| 572 | - err_signup_team_empty: "Indique une équipe initiale.", | |
| 573 | - err_signup_team_ambiguous: "Plusieurs équipes portent ce nom : utilise l'identifiant de celle que tu veux rejoindre.", | |
| 574 | - err_signup_team_unknown: "Aucune équipe ne porte cet identifiant.", | |
| 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.", | |
| 575 | 570 | err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.", |
| 576 | 571 | err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.", |
| 577 | 572 | err_password_mismatch: "La confirmation ne correspond pas.", |
| @@ -816,11 +811,12 @@ pub static EN: Tr = Tr { | ||
| 816 | 811 | toast_team_renamed: "Team renamed", |
| 817 | 812 | toast_joined_pat: "You joined {}", |
| 818 | 813 | toast_team_left: "Team left", |
| 814 | + toast_team_left_deleted: "Team left — it was empty, so it was deleted", | |
| 819 | 815 | err_team_name_empty: "The team name cannot be empty", |
| 820 | 816 | err_last_team: "You cannot leave your last team", |
| 821 | 817 | err_password_short: "The new password must be at least 8 characters.", |
| 822 | 818 | signup_title: "Create an account", |
| 823 | - signup_intro: "Pick a username and a password, name your team, then enter the signup key.", | |
| 819 | + signup_intro: "Pick a username and a password, then enter the signup key.", | |
| 824 | 820 | signup_key_label: "Signup key", |
| 825 | 821 | signup_key_hint: "Ask someone who already uses Ex Ego for it.", |
| 826 | 822 | signup_submit: "Create the account", |
| @@ -829,11 +825,7 @@ pub static EN: Tr = Tr { | ||
| 829 | 825 | err_signup_key: "Invalid signup key.", |
| 830 | 826 | err_signup_username_taken: "That username is already taken.", |
| 831 | 827 | err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.", |
| 832 | - signup_team_label: "Initial team", | |
| 833 | - signup_team_hint: "The name of an existing team to join it, or a new name to create your own. If several teams share a name, paste its identifier instead.", | |
| 834 | - err_signup_team_empty: "Enter an initial team.", | |
| 835 | - err_signup_team_ambiguous: "Several teams share that name: use the identifier of the one you want to join.", | |
| 836 | - err_signup_team_unknown: "No team has that identifier.", | |
| 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.", | |
| 837 | 829 | err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.", |
| 838 | 830 | err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.", |
| 839 | 831 | err_password_mismatch: "The confirmation does not match.", |
| @@ -1078,11 +1070,12 @@ pub static DE: Tr = Tr { | ||
| 1078 | 1070 | toast_team_renamed: "Team umbenannt", |
| 1079 | 1071 | toast_joined_pat: "Du bist {} beigetreten", |
| 1080 | 1072 | toast_team_left: "Team verlassen", |
| 1073 | + toast_team_left_deleted: "Team verlassen — es war leer und wurde gelöscht", | |
| 1081 | 1074 | err_team_name_empty: "Der Teamname darf nicht leer sein", |
| 1082 | 1075 | err_last_team: "Du kannst dein letztes Team nicht verlassen", |
| 1083 | 1076 | err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", |
| 1084 | 1077 | signup_title: "Konto erstellen", |
| 1085 | - signup_intro: "Wähle einen Benutzernamen und ein Passwort, nenne dein Team und gib den Anmeldeschlüssel ein.", | |
| 1078 | + signup_intro: "Wähle einen Benutzernamen und ein Passwort und gib den Anmeldeschlüssel ein.", | |
| 1086 | 1079 | signup_key_label: "Anmeldeschlüssel", |
| 1087 | 1080 | signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.", |
| 1088 | 1081 | signup_submit: "Konto erstellen", |
| @@ -1091,11 +1084,7 @@ pub static DE: Tr = Tr { | ||
| 1091 | 1084 | err_signup_key: "Ungültiger Anmeldeschlüssel.", |
| 1092 | 1085 | err_signup_username_taken: "Dieser Benutzername ist schon vergeben.", |
| 1093 | 1086 | err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.", |
| 1094 | - signup_team_label: "Erstes Team", | |
| 1095 | - signup_team_hint: "Der Name eines bestehenden Teams, um beizutreten, oder ein neuer Name für dein eigenes. Tragen mehrere Teams denselben Namen, füge lieber die Kennung ein.", | |
| 1096 | - err_signup_team_empty: "Gib ein erstes Team an.", | |
| 1097 | - err_signup_team_ambiguous: "Mehrere Teams tragen diesen Namen: Nimm die Kennung des gewünschten Teams.", | |
| 1098 | - err_signup_team_unknown: "Kein Team hat diese Kennung.", | |
| 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.", | |
| 1099 | 1088 | err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.", |
| 1100 | 1089 | err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.", |
| 1101 | 1090 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", |
| @@ -1340,11 +1329,12 @@ pub static PT_BR: Tr = Tr { | ||
| 1340 | 1329 | toast_team_renamed: "Equipe renomeada", |
| 1341 | 1330 | toast_joined_pat: "Você entrou em {}", |
| 1342 | 1331 | toast_team_left: "Você saiu da equipe", |
| 1332 | + toast_team_left_deleted: "Você saiu da equipe — ela estava vazia e foi excluída", | |
| 1343 | 1333 | err_team_name_empty: "O nome da equipe não pode ficar vazio", |
| 1344 | 1334 | err_last_team: "Você não pode sair da sua última equipe", |
| 1345 | 1335 | err_password_short: "A nova senha deve ter pelo menos 8 caracteres.", |
| 1346 | 1336 | signup_title: "Criar uma conta", |
| 1347 | - signup_intro: "Escolha um usuário e uma senha, indique sua equipe e informe a chave de inscrição.", | |
| 1337 | + signup_intro: "Escolha um usuário e uma senha, e informe a chave de inscrição.", | |
| 1348 | 1338 | signup_key_label: "Chave de inscrição", |
| 1349 | 1339 | signup_key_hint: "Peça a alguém que já usa o Ex Ego.", |
| 1350 | 1340 | signup_submit: "Criar a conta", |
| @@ -1353,11 +1343,7 @@ pub static PT_BR: Tr = Tr { | ||
| 1353 | 1343 | err_signup_key: "Chave de inscrição inválida.", |
| 1354 | 1344 | err_signup_username_taken: "Esse usuário já existe.", |
| 1355 | 1345 | err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.", |
| 1356 | - signup_team_label: "Equipe inicial", | |
| 1357 | - signup_team_hint: "O nome de uma equipe existente para entrar nela, ou um nome novo para criar a sua. Se várias equipes tiverem o mesmo nome, cole o identificador.", | |
| 1358 | - err_signup_team_empty: "Informe uma equipe inicial.", | |
| 1359 | - err_signup_team_ambiguous: "Várias equipes têm esse nome: use o identificador da que você quer entrar.", | |
| 1360 | - err_signup_team_unknown: "Nenhuma equipe tem esse identificador.", | |
| 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.", | |
| 1361 | 1347 | err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.", |
| 1362 | 1348 | err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.", |
| 1363 | 1349 | err_password_mismatch: "A confirmação não confere.", |
| @@ -261,6 +261,8 @@ pub struct Tr { | |||
| 261 | pub toast_team_renamed: &'static str, | 261 | pub toast_team_renamed: &'static str, |
| 262 | pub toast_joined_pat: &'static str, | 262 | pub toast_joined_pat: &'static str, |
| 263 | pub toast_team_left: &'static str, | 263 | pub toast_team_left: &'static str, |
| 264 | + /// L'équipe quittée était vide de tout : elle a disparu avec le départ. | ||
| 265 | + pub toast_team_left_deleted: &'static str, | ||
| 264 | pub err_team_name_empty: &'static str, | 266 | pub err_team_name_empty: &'static str, |
| 265 | pub err_last_team: &'static str, | 267 | pub err_last_team: &'static str, |
| 266 | pub err_password_short: &'static str, | 268 | pub err_password_short: &'static str, |
| @@ -275,11 +277,7 @@ pub struct Tr { | |||
| 275 | pub err_signup_key: &'static str, | 277 | pub err_signup_key: &'static str, |
| 276 | pub err_signup_username_taken: &'static str, | 278 | pub err_signup_username_taken: &'static str, |
| 277 | pub err_signup_username_invalid: &'static str, | 279 | pub err_signup_username_invalid: &'static str, |
| 278 | - pub signup_team_label: &'static str, | ||
| 279 | pub signup_team_hint: &'static str, | 280 | pub signup_team_hint: &'static str, |
| 280 | - pub err_signup_team_empty: &'static str, | ||
| 281 | - pub err_signup_team_ambiguous: &'static str, | ||
| 282 | - pub err_signup_team_unknown: &'static str, | ||
| 283 | pub err_signup_rate_limited: &'static str, | 281 | pub err_signup_rate_limited: &'static str, |
| 284 | pub err_signup_too_many_attempts: &'static str, | 282 | pub err_signup_too_many_attempts: &'static str, |
| 285 | pub err_password_mismatch: &'static str, | 283 | pub err_password_mismatch: &'static str, |
| @@ -554,11 +552,12 @@ pub static FR: Tr = Tr { | |||
| 554 | toast_team_renamed: "Équipe renommée", | 552 | toast_team_renamed: "Équipe renommée", |
| 555 | toast_joined_pat: "Tu as rejoint {}", | 553 | toast_joined_pat: "Tu as rejoint {}", |
| 556 | toast_team_left: "Équipe quittée", | 554 | toast_team_left: "Équipe quittée", |
| 555 | + toast_team_left_deleted: "Équipe quittée — elle était vide, elle a été supprimée", | ||
| 557 | err_team_name_empty: "Le nom d'équipe ne peut pas être vide", | 556 | err_team_name_empty: "Le nom d'équipe ne peut pas être vide", |
| 558 | err_last_team: "Impossible de quitter ta dernière équipe", | 557 | err_last_team: "Impossible de quitter ta dernière équipe", |
| 559 | err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", | 558 | err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", |
| 560 | signup_title: "Créer un compte", | 559 | signup_title: "Créer un compte", |
| 561 | - signup_intro: "Choisis un identifiant et un mot de passe, indique ton équipe, et saisis la clé d'inscription.", | 560 | + signup_intro: "Choisis un identifiant et un mot de passe, et saisis la clé d'inscription.", |
| 562 | signup_key_label: "Clé d'inscription", | 561 | signup_key_label: "Clé d'inscription", |
| 563 | signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.", | 562 | signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.", |
| 564 | signup_submit: "Créer le compte", | 563 | signup_submit: "Créer le compte", |
| @@ -567,11 +566,7 @@ pub static FR: Tr = Tr { | |||
| 567 | err_signup_key: "Clé d'inscription invalide.", | 566 | err_signup_key: "Clé d'inscription invalide.", |
| 568 | err_signup_username_taken: "Cet identifiant est déjà pris.", | 567 | err_signup_username_taken: "Cet identifiant est déjà pris.", |
| 569 | err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.", | 568 | err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.", |
| 570 | - signup_team_label: "Équipe initiale", | 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.", |
| 571 | - signup_team_hint: "Le nom d'une équipe existante pour la rejoindre, ou un nouveau nom pour créer la tienne. Si plusieurs équipes portent le même nom, colle plutôt son identifiant.", | ||
| 572 | - err_signup_team_empty: "Indique une équipe initiale.", | ||
| 573 | - err_signup_team_ambiguous: "Plusieurs équipes portent ce nom : utilise l'identifiant de celle que tu veux rejoindre.", | ||
| 574 | - err_signup_team_unknown: "Aucune équipe ne porte cet identifiant.", | ||
| 575 | err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.", | 570 | err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.", |
| 576 | err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.", | 571 | err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.", |
| 577 | err_password_mismatch: "La confirmation ne correspond pas.", | 572 | err_password_mismatch: "La confirmation ne correspond pas.", |
| @@ -816,11 +811,12 @@ pub static EN: Tr = Tr { | |||
| 816 | toast_team_renamed: "Team renamed", | 811 | toast_team_renamed: "Team renamed", |
| 817 | toast_joined_pat: "You joined {}", | 812 | toast_joined_pat: "You joined {}", |
| 818 | toast_team_left: "Team left", | 813 | toast_team_left: "Team left", |
| 814 | + toast_team_left_deleted: "Team left — it was empty, so it was deleted", | ||
| 819 | err_team_name_empty: "The team name cannot be empty", | 815 | err_team_name_empty: "The team name cannot be empty", |
| 820 | err_last_team: "You cannot leave your last team", | 816 | err_last_team: "You cannot leave your last team", |
| 821 | err_password_short: "The new password must be at least 8 characters.", | 817 | err_password_short: "The new password must be at least 8 characters.", |
| 822 | signup_title: "Create an account", | 818 | signup_title: "Create an account", |
| 823 | - signup_intro: "Pick a username and a password, name your team, then enter the signup key.", | 819 | + signup_intro: "Pick a username and a password, then enter the signup key.", |
| 824 | signup_key_label: "Signup key", | 820 | signup_key_label: "Signup key", |
| 825 | signup_key_hint: "Ask someone who already uses Ex Ego for it.", | 821 | signup_key_hint: "Ask someone who already uses Ex Ego for it.", |
| 826 | signup_submit: "Create the account", | 822 | signup_submit: "Create the account", |
| @@ -829,11 +825,7 @@ pub static EN: Tr = Tr { | |||
| 829 | err_signup_key: "Invalid signup key.", | 825 | err_signup_key: "Invalid signup key.", |
| 830 | err_signup_username_taken: "That username is already taken.", | 826 | err_signup_username_taken: "That username is already taken.", |
| 831 | err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.", | 827 | err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.", |
| 832 | - signup_team_label: "Initial team", | 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.", |
| 833 | - signup_team_hint: "The name of an existing team to join it, or a new name to create your own. If several teams share a name, paste its identifier instead.", | ||
| 834 | - err_signup_team_empty: "Enter an initial team.", | ||
| 835 | - err_signup_team_ambiguous: "Several teams share that name: use the identifier of the one you want to join.", | ||
| 836 | - err_signup_team_unknown: "No team has that identifier.", | ||
| 837 | err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.", | 829 | err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.", |
| 838 | err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.", | 830 | err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.", |
| 839 | err_password_mismatch: "The confirmation does not match.", | 831 | err_password_mismatch: "The confirmation does not match.", |
| @@ -1078,11 +1070,12 @@ pub static DE: Tr = Tr { | |||
| 1078 | toast_team_renamed: "Team umbenannt", | 1070 | toast_team_renamed: "Team umbenannt", |
| 1079 | toast_joined_pat: "Du bist {} beigetreten", | 1071 | toast_joined_pat: "Du bist {} beigetreten", |
| 1080 | toast_team_left: "Team verlassen", | 1072 | toast_team_left: "Team verlassen", |
| 1073 | + toast_team_left_deleted: "Team verlassen — es war leer und wurde gelöscht", | ||
| 1081 | err_team_name_empty: "Der Teamname darf nicht leer sein", | 1074 | err_team_name_empty: "Der Teamname darf nicht leer sein", |
| 1082 | err_last_team: "Du kannst dein letztes Team nicht verlassen", | 1075 | err_last_team: "Du kannst dein letztes Team nicht verlassen", |
| 1083 | err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", | 1076 | err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", |
| 1084 | signup_title: "Konto erstellen", | 1077 | signup_title: "Konto erstellen", |
| 1085 | - signup_intro: "Wähle einen Benutzernamen und ein Passwort, nenne dein Team und gib den Anmeldeschlüssel ein.", | 1078 | + signup_intro: "Wähle einen Benutzernamen und ein Passwort und gib den Anmeldeschlüssel ein.", |
| 1086 | signup_key_label: "Anmeldeschlüssel", | 1079 | signup_key_label: "Anmeldeschlüssel", |
| 1087 | signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.", | 1080 | signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.", |
| 1088 | signup_submit: "Konto erstellen", | 1081 | signup_submit: "Konto erstellen", |
| @@ -1091,11 +1084,7 @@ pub static DE: Tr = Tr { | |||
| 1091 | err_signup_key: "Ungültiger Anmeldeschlüssel.", | 1084 | err_signup_key: "Ungültiger Anmeldeschlüssel.", |
| 1092 | err_signup_username_taken: "Dieser Benutzername ist schon vergeben.", | 1085 | err_signup_username_taken: "Dieser Benutzername ist schon vergeben.", |
| 1093 | err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.", | 1086 | err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.", |
| 1094 | - signup_team_label: "Erstes Team", | 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.", |
| 1095 | - signup_team_hint: "Der Name eines bestehenden Teams, um beizutreten, oder ein neuer Name für dein eigenes. Tragen mehrere Teams denselben Namen, füge lieber die Kennung ein.", | ||
| 1096 | - err_signup_team_empty: "Gib ein erstes Team an.", | ||
| 1097 | - err_signup_team_ambiguous: "Mehrere Teams tragen diesen Namen: Nimm die Kennung des gewünschten Teams.", | ||
| 1098 | - err_signup_team_unknown: "Kein Team hat diese Kennung.", | ||
| 1099 | err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.", | 1088 | err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.", |
| 1100 | err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.", | 1089 | err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.", |
| 1101 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", | 1090 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", |
| @@ -1340,11 +1329,12 @@ pub static PT_BR: Tr = Tr { | |||
| 1340 | toast_team_renamed: "Equipe renomeada", | 1329 | toast_team_renamed: "Equipe renomeada", |
| 1341 | toast_joined_pat: "Você entrou em {}", | 1330 | toast_joined_pat: "Você entrou em {}", |
| 1342 | toast_team_left: "Você saiu da equipe", | 1331 | toast_team_left: "Você saiu da equipe", |
| 1332 | + toast_team_left_deleted: "Você saiu da equipe — ela estava vazia e foi excluída", | ||
| 1343 | err_team_name_empty: "O nome da equipe não pode ficar vazio", | 1333 | err_team_name_empty: "O nome da equipe não pode ficar vazio", |
| 1344 | err_last_team: "Você não pode sair da sua última equipe", | 1334 | err_last_team: "Você não pode sair da sua última equipe", |
| 1345 | err_password_short: "A nova senha deve ter pelo menos 8 caracteres.", | 1335 | err_password_short: "A nova senha deve ter pelo menos 8 caracteres.", |
| 1346 | signup_title: "Criar uma conta", | 1336 | signup_title: "Criar uma conta", |
| 1347 | - signup_intro: "Escolha um usuário e uma senha, indique sua equipe e informe a chave de inscrição.", | 1337 | + signup_intro: "Escolha um usuário e uma senha, e informe a chave de inscrição.", |
| 1348 | signup_key_label: "Chave de inscrição", | 1338 | signup_key_label: "Chave de inscrição", |
| 1349 | signup_key_hint: "Peça a alguém que já usa o Ex Ego.", | 1339 | signup_key_hint: "Peça a alguém que já usa o Ex Ego.", |
| 1350 | signup_submit: "Criar a conta", | 1340 | signup_submit: "Criar a conta", |
| @@ -1353,11 +1343,7 @@ pub static PT_BR: Tr = Tr { | |||
| 1353 | err_signup_key: "Chave de inscrição inválida.", | 1343 | err_signup_key: "Chave de inscrição inválida.", |
| 1354 | err_signup_username_taken: "Esse usuário já existe.", | 1344 | err_signup_username_taken: "Esse usuário já existe.", |
| 1355 | err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.", | 1345 | err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.", |
| 1356 | - signup_team_label: "Equipe inicial", | 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.", |
| 1357 | - signup_team_hint: "O nome de uma equipe existente para entrar nela, ou um nome novo para criar a sua. Se várias equipes tiverem o mesmo nome, cole o identificador.", | ||
| 1358 | - err_signup_team_empty: "Informe uma equipe inicial.", | ||
| 1359 | - err_signup_team_ambiguous: "Várias equipes têm esse nome: use o identificador da que você quer entrar.", | ||
| 1360 | - err_signup_team_unknown: "Nenhuma equipe tem esse identificador.", | ||
| 1361 | err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.", | 1347 | err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.", |
| 1362 | err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.", | 1348 | err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.", |
| 1363 | err_password_mismatch: "A confirmação não confere.", | 1349 | err_password_mismatch: "A confirmação não confere.", |
modified
src/routes/settings.rs +1 -0 | @@ -78,6 +78,7 @@ pub async fn settings_page( | ||
| 78 | 78 | Some("equipe-creee") => Some(p1(tr.team_created_pat, &ctx.team_name)), |
| 79 | 79 | Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), |
| 80 | 80 | Some("quittee") => Some(tr.toast_team_left.to_string()), |
| 81 | + Some("quittee-supprimee") => Some(tr.toast_team_left_deleted.to_string()), | |
| 81 | 82 | Some("langue") => Some(tr.toast_lang_updated.to_string()), |
| 82 | 83 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), |
| 83 | 84 | Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), |
| @@ -78,6 +78,7 @@ pub async fn settings_page( | |||
| 78 | Some("equipe-creee") => Some(p1(tr.team_created_pat, &ctx.team_name)), | 78 | Some("equipe-creee") => Some(p1(tr.team_created_pat, &ctx.team_name)), |
| 79 | Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), | 79 | Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), |
| 80 | Some("quittee") => Some(tr.toast_team_left.to_string()), | 80 | Some("quittee") => Some(tr.toast_team_left.to_string()), |
| 81 | + Some("quittee-supprimee") => Some(tr.toast_team_left_deleted.to_string()), | ||
| 81 | Some("langue") => Some(tr.toast_lang_updated.to_string()), | 82 | Some("langue") => Some(tr.toast_lang_updated.to_string()), |
| 82 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), | 83 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), |
| 83 | Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), | 84 | Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), |
modified
src/routes/signup.rs +8 -31 | @@ -18,8 +18,7 @@ use subtle::ConstantTimeEq; | ||
| 18 | 18 | |
| 19 | 19 | use crate::auth::extractor::AuthUser; |
| 20 | 20 | use crate::auth::password; |
| 21 | -use crate::db::teams::{self, Resolved}; | |
| 22 | -use crate::db::users::{self, InitialTeam, Signup}; | |
| 21 | +use crate::db::users::{self, Signup}; | |
| 23 | 22 | use crate::error::AppError; |
| 24 | 23 | use crate::i18n::Tr; |
| 25 | 24 | use crate::ratelimit::client_ip; |
| @@ -35,17 +34,15 @@ pub struct SignupPage { | ||
| 35 | 34 | pub tr: &'static Tr, |
| 36 | 35 | pub lang_code: &'static str, |
| 37 | 36 | pub error: Option<&'static str>, |
| 38 | - /// Identifiant et équipe rejoués dans les champs après une erreur. | |
| 37 | + /// Identifiant rejoué dans le champ après une erreur. | |
| 39 | 38 | pub username: String, |
| 40 | - pub team: String, | |
| 41 | 39 | } |
| 42 | 40 | |
| 43 | 41 | #[derive(Deserialize)] |
| 44 | 42 | pub struct SignupQuery { |
| 45 | 43 | err: Option<String>, |
| 46 | - /// Champs à rejouer (jamais le mot de passe ni la clé). | |
| 44 | + /// Identifiant à rejouer (jamais le mot de passe ni la clé). | |
| 47 | 45 | nom: Option<String>, |
| 48 | - equipe: Option<String>, | |
| 49 | 46 | } |
| 50 | 47 | |
| 51 | 48 | /// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout |
| @@ -72,15 +69,11 @@ pub async fn signup_page( | ||
| 72 | 69 | Some("pris") => Some(tr.err_signup_username_taken), |
| 73 | 70 | Some("nom") => Some(tr.err_signup_username_invalid), |
| 74 | 71 | Some("court") => Some(tr.err_password_short), |
| 75 | - Some("equipe-vide") => Some(tr.err_signup_team_empty), | |
| 76 | - Some("equipe-ambigue") => Some(tr.err_signup_team_ambiguous), | |
| 77 | - Some("equipe-inconnue") => Some(tr.err_signup_team_unknown), | |
| 78 | 72 | Some("trop") => Some(tr.err_signup_rate_limited), |
| 79 | 73 | Some("essais") => Some(tr.err_signup_too_many_attempts), |
| 80 | 74 | _ => None, |
| 81 | 75 | }, |
| 82 | 76 | username: q.nom.unwrap_or_default(), |
| 83 | - team: q.equipe.unwrap_or_default(), | |
| 84 | 77 | }) |
| 85 | 78 | .into_response()) |
| 86 | 79 | } |
| @@ -90,8 +83,6 @@ pub struct SignupForm { | ||
| 90 | 83 | username: String, |
| 91 | 84 | password: String, |
| 92 | 85 | cle: String, |
| 93 | - /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise. | |
| 94 | - equipe: String, | |
| 95 | 86 | } |
| 96 | 87 | |
| 97 | 88 | /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de |
| @@ -134,29 +125,15 @@ pub async fn signup_submit( | ||
| 134 | 125 | if form.password.len() < 8 { |
| 135 | 126 | return Ok(back("court", &form)); |
| 136 | 127 | } |
| 137 | - let wanted_team = form.equipe.trim().to_string(); | |
| 138 | - if wanted_team.is_empty() { | |
| 139 | - return Ok(back("equipe-vide", &form)); | |
| 140 | - } | |
| 141 | - | |
| 142 | 128 | // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit. |
| 143 | 129 | if !state.signups.allows(&ip) { |
| 144 | 130 | tracing::warn!(%ip, "inscription refusée : quota horaire atteint"); |
| 145 | 131 | return Ok(back("trop", &form)); |
| 146 | 132 | } |
| 147 | 133 | |
| 148 | - let team = match teams::resolve(&state.pool, &wanted_team).await? { | |
| 149 | - Resolved::One(t) => InitialTeam::Existing(t.id), | |
| 150 | - Resolved::Ambiguous(_) => return Ok(back("equipe-ambigue", &form)), | |
| 151 | - // Un identifiant qui ne désigne rien est une faute de frappe, pas une | |
| 152 | - // intention de créer : seul un NOM libre ouvre une nouvelle équipe. | |
| 153 | - Resolved::None { was_id: true } => return Ok(back("equipe-inconnue", &form)), | |
| 154 | - Resolved::None { was_id: false } => InitialTeam::Create(wanted_team), | |
| 155 | - }; | |
| 156 | - | |
| 157 | 134 | let hash = password::hash(&form.password).map_err(AppError::Internal)?; |
| 158 | 135 | let lang = crate::config::default_lang(); |
| 159 | - match users::signup(&state.pool, &username, &hash, lang.code(), team).await? { | |
| 136 | + match users::signup(&state.pool, &username, &hash, lang.code()).await? { | |
| 160 | 137 | Signup::UsernameTaken => Ok(back("pris", &form)), |
| 161 | 138 | Signup::Created { user_id, team_id } => { |
| 162 | 139 | state.signups.record(&ip); |
| @@ -170,12 +147,12 @@ pub async fn signup_submit( | ||
| 170 | 147 | } |
| 171 | 148 | } |
| 172 | 149 | |
| 173 | -/// Retour au formulaire avec l'erreur et les champs non secrets rejoués. | |
| 150 | +/// Retour au formulaire avec l'erreur et l'identifiant rejoué (jamais le mot | |
| 151 | +/// de passe ni la clé). | |
| 174 | 152 | fn back(err: &str, form: &SignupForm) -> Response { |
| 175 | 153 | Redirect::to(&format!( |
| 176 | - "/inscription?err={err}&nom={}&equipe={}", | |
| 177 | - crate::routes::urlenc(form.username.trim()), | |
| 178 | - crate::routes::urlenc(form.equipe.trim()) | |
| 154 | + "/inscription?err={err}&nom={}", | |
| 155 | + crate::routes::urlenc(form.username.trim()) | |
| 179 | 156 | )) |
| 180 | 157 | .into_response() |
| 181 | 158 | } |
| @@ -18,8 +18,7 @@ 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::password; |
| 21 | -use crate::db::teams::{self, Resolved}; | 21 | +use crate::db::users::{self, Signup}; |
| 22 | -use crate::db::users::{self, InitialTeam, Signup}; | ||
| 23 | use crate::error::AppError; | 22 | use crate::error::AppError; |
| 24 | use crate::i18n::Tr; | 23 | use crate::i18n::Tr; |
| 25 | use crate::ratelimit::client_ip; | 24 | use crate::ratelimit::client_ip; |
| @@ -35,17 +34,15 @@ pub struct SignupPage { | |||
| 35 | pub tr: &'static Tr, | 34 | pub tr: &'static Tr, |
| 36 | pub lang_code: &'static str, | 35 | pub lang_code: &'static str, |
| 37 | pub error: Option<&'static str>, | 36 | pub error: Option<&'static str>, |
| 38 | - /// Identifiant et équipe rejoués dans les champs après une erreur. | 37 | + /// Identifiant rejoué dans le champ après une erreur. |
| 39 | pub username: String, | 38 | pub username: String, |
| 40 | - pub team: String, | ||
| 41 | } | 39 | } |
| 42 | 40 | ||
| 43 | #[derive(Deserialize)] | 41 | #[derive(Deserialize)] |
| 44 | pub struct SignupQuery { | 42 | pub struct SignupQuery { |
| 45 | err: Option<String>, | 43 | err: Option<String>, |
| 46 | - /// Champs à rejouer (jamais le mot de passe ni la clé). | 44 | + /// Identifiant à rejouer (jamais le mot de passe ni la clé). |
| 47 | nom: Option<String>, | 45 | nom: Option<String>, |
| 48 | - equipe: Option<String>, | ||
| 49 | } | 46 | } |
| 50 | 47 | ||
| 51 | /// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout | 48 | /// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout |
| @@ -72,15 +69,11 @@ pub async fn signup_page( | |||
| 72 | Some("pris") => Some(tr.err_signup_username_taken), | 69 | Some("pris") => Some(tr.err_signup_username_taken), |
| 73 | Some("nom") => Some(tr.err_signup_username_invalid), | 70 | Some("nom") => Some(tr.err_signup_username_invalid), |
| 74 | Some("court") => Some(tr.err_password_short), | 71 | Some("court") => Some(tr.err_password_short), |
| 75 | - Some("equipe-vide") => Some(tr.err_signup_team_empty), | ||
| 76 | - Some("equipe-ambigue") => Some(tr.err_signup_team_ambiguous), | ||
| 77 | - Some("equipe-inconnue") => Some(tr.err_signup_team_unknown), | ||
| 78 | Some("trop") => Some(tr.err_signup_rate_limited), | 72 | Some("trop") => Some(tr.err_signup_rate_limited), |
| 79 | Some("essais") => Some(tr.err_signup_too_many_attempts), | 73 | Some("essais") => Some(tr.err_signup_too_many_attempts), |
| 80 | _ => None, | 74 | _ => None, |
| 81 | }, | 75 | }, |
| 82 | username: q.nom.unwrap_or_default(), | 76 | username: q.nom.unwrap_or_default(), |
| 83 | - team: q.equipe.unwrap_or_default(), | ||
| 84 | }) | 77 | }) |
| 85 | .into_response()) | 78 | .into_response()) |
| 86 | } | 79 | } |
| @@ -90,8 +83,6 @@ pub struct SignupForm { | |||
| 90 | username: String, | 83 | username: String, |
| 91 | password: String, | 84 | password: String, |
| 92 | cle: String, | 85 | cle: String, |
| 93 | - /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise. | ||
| 94 | - equipe: String, | ||
| 95 | } | 86 | } |
| 96 | 87 | ||
| 97 | /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de | 88 | /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de |
| @@ -134,29 +125,15 @@ pub async fn signup_submit( | |||
| 134 | if form.password.len() < 8 { | 125 | if form.password.len() < 8 { |
| 135 | return Ok(back("court", &form)); | 126 | return Ok(back("court", &form)); |
| 136 | } | 127 | } |
| 137 | - let wanted_team = form.equipe.trim().to_string(); | ||
| 138 | - if wanted_team.is_empty() { | ||
| 139 | - return Ok(back("equipe-vide", &form)); | ||
| 140 | - } | ||
| 141 | - | ||
| 142 | // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit. | 128 | // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit. |
| 143 | if !state.signups.allows(&ip) { | 129 | if !state.signups.allows(&ip) { |
| 144 | tracing::warn!(%ip, "inscription refusée : quota horaire atteint"); | 130 | tracing::warn!(%ip, "inscription refusée : quota horaire atteint"); |
| 145 | return Ok(back("trop", &form)); | 131 | return Ok(back("trop", &form)); |
| 146 | } | 132 | } |
| 147 | 133 | ||
| 148 | - let team = match teams::resolve(&state.pool, &wanted_team).await? { | ||
| 149 | - Resolved::One(t) => InitialTeam::Existing(t.id), | ||
| 150 | - Resolved::Ambiguous(_) => return Ok(back("equipe-ambigue", &form)), | ||
| 151 | - // Un identifiant qui ne désigne rien est une faute de frappe, pas une | ||
| 152 | - // intention de créer : seul un NOM libre ouvre une nouvelle équipe. | ||
| 153 | - Resolved::None { was_id: true } => return Ok(back("equipe-inconnue", &form)), | ||
| 154 | - Resolved::None { was_id: false } => InitialTeam::Create(wanted_team), | ||
| 155 | - }; | ||
| 156 | - | ||
| 157 | let hash = password::hash(&form.password).map_err(AppError::Internal)?; | 134 | let hash = password::hash(&form.password).map_err(AppError::Internal)?; |
| 158 | let lang = crate::config::default_lang(); | 135 | let lang = crate::config::default_lang(); |
| 159 | - match users::signup(&state.pool, &username, &hash, lang.code(), team).await? { | 136 | + match users::signup(&state.pool, &username, &hash, lang.code()).await? { |
| 160 | Signup::UsernameTaken => Ok(back("pris", &form)), | 137 | Signup::UsernameTaken => Ok(back("pris", &form)), |
| 161 | Signup::Created { user_id, team_id } => { | 138 | Signup::Created { user_id, team_id } => { |
| 162 | state.signups.record(&ip); | 139 | state.signups.record(&ip); |
| @@ -170,12 +147,12 @@ pub async fn signup_submit( | |||
| 170 | } | 147 | } |
| 171 | } | 148 | } |
| 172 | 149 | ||
| 173 | -/// Retour au formulaire avec l'erreur et les champs non secrets rejoués. | 150 | +/// Retour au formulaire avec l'erreur et l'identifiant rejoué (jamais le mot |
| 151 | +/// de passe ni la clé). | ||
| 174 | fn back(err: &str, form: &SignupForm) -> Response { | 152 | fn back(err: &str, form: &SignupForm) -> Response { |
| 175 | Redirect::to(&format!( | 153 | Redirect::to(&format!( |
| 176 | - "/inscription?err={err}&nom={}&equipe={}", | 154 | + "/inscription?err={err}&nom={}", |
| 177 | - crate::routes::urlenc(form.username.trim()), | 155 | + crate::routes::urlenc(form.username.trim()) |
| 178 | - crate::routes::urlenc(form.equipe.trim()) | ||
| 179 | )) | 156 | )) |
| 180 | .into_response() | 157 | .into_response() |
| 181 | } | 158 | } |
modified
src/routes/teams.rs +14 -3 | @@ -111,16 +111,27 @@ pub async fn team_leave( | ||
| 111 | 111 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); |
| 112 | 112 | } |
| 113 | 113 | // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté. |
| 114 | + // Avant la transaction : `delete_if_empty` refuse de supprimer une équipe | |
| 115 | + // qui porte encore un timer. | |
| 114 | 116 | timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?; |
| 115 | - // Mes todos « en cours » repassent à faire, mes assignations disparaissent. | |
| 116 | 117 | let mut tx = state.pool.begin().await?; |
| 118 | + // Mes todos « en cours » repassent à faire, mes assignations disparaissent. | |
| 117 | 119 | crate::db::todos::on_member_leave(&mut tx, ctx.team_id, ctx.user.id).await?; |
| 120 | + teams::remove_member_tx(&mut tx, ctx.team_id, ctx.user.id).await?; | |
| 121 | + // Départ du dernier membre d'une équipe qui ne contient rien (typiquement | |
| 122 | + // l'équipe neutre d'une inscription, une fois rejointe une vraie équipe) : | |
| 123 | + // elle disparaît au lieu de traîner. Une équipe qui porte des catégories | |
| 124 | + // garde son historique et reste en base. | |
| 125 | + let deleted = teams::delete_if_empty(&mut tx, ctx.team_id).await?; | |
| 118 | 126 | tx.commit().await?; |
| 119 | - teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?; | |
| 120 | 127 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. |
| 121 | 128 | Ok(( |
| 122 | 129 | jar.remove(clear_team_cookie()), |
| 123 | - Redirect::to("/parametres?ok=quittee"), | |
| 130 | + Redirect::to(if deleted { | |
| 131 | + "/parametres?ok=quittee-supprimee" | |
| 132 | + } else { | |
| 133 | + "/parametres?ok=quittee" | |
| 134 | + }), | |
| 124 | 135 | ) |
| 125 | 136 | .into_response()) |
| 126 | 137 | } |
| @@ -111,16 +111,27 @@ pub async fn team_leave( | |||
| 111 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); | 111 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); |
| 112 | } | 112 | } |
| 113 | // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté. | 113 | // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté. |
| 114 | + // Avant la transaction : `delete_if_empty` refuse de supprimer une équipe | ||
| 115 | + // qui porte encore un timer. | ||
| 114 | timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?; | 116 | timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?; |
| 115 | - // Mes todos « en cours » repassent à faire, mes assignations disparaissent. | ||
| 116 | let mut tx = state.pool.begin().await?; | 117 | let mut tx = state.pool.begin().await?; |
| 118 | + // Mes todos « en cours » repassent à faire, mes assignations disparaissent. | ||
| 117 | crate::db::todos::on_member_leave(&mut tx, ctx.team_id, ctx.user.id).await?; | 119 | crate::db::todos::on_member_leave(&mut tx, ctx.team_id, ctx.user.id).await?; |
| 120 | + teams::remove_member_tx(&mut tx, ctx.team_id, ctx.user.id).await?; | ||
| 121 | + // Départ du dernier membre d'une équipe qui ne contient rien (typiquement | ||
| 122 | + // l'équipe neutre d'une inscription, une fois rejointe une vraie équipe) : | ||
| 123 | + // elle disparaît au lieu de traîner. Une équipe qui porte des catégories | ||
| 124 | + // garde son historique et reste en base. | ||
| 125 | + let deleted = teams::delete_if_empty(&mut tx, ctx.team_id).await?; | ||
| 118 | tx.commit().await?; | 126 | tx.commit().await?; |
| 119 | - teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?; | ||
| 120 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. | 127 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. |
| 121 | Ok(( | 128 | Ok(( |
| 122 | jar.remove(clear_team_cookie()), | 129 | jar.remove(clear_team_cookie()), |
| 123 | - Redirect::to("/parametres?ok=quittee"), | 130 | + Redirect::to(if deleted { |
| 131 | + "/parametres?ok=quittee-supprimee" | ||
| 132 | + } else { | ||
| 133 | + "/parametres?ok=quittee" | ||
| 134 | + }), | ||
| 124 | ) | 135 | ) |
| 125 | .into_response()) | 136 | .into_response()) |
| 126 | } | 137 | } |
modified
templates/signup.html +1 -7 | @@ -31,13 +31,6 @@ | ||
| 31 | 31 | <input class="input" id="password" name="password" type="password" |
| 32 | 32 | autocomplete="new-password" required minlength="8" style="font-size:18px"> |
| 33 | 33 | </div> |
| 34 | - <div class="field" style="margin-bottom:var(--space-2)"> | |
| 35 | - <label for="equipe" style="font-size:18px">{{ tr.signup_team_label }}</label> | |
| 36 | - <input class="input" id="equipe" name="equipe" value="{{ team }}" | |
| 37 | - autocapitalize="none" autocorrect="off" required | |
| 38 | - aria-describedby="equipe-aide" style="font-size:18px"> | |
| 39 | - </div> | |
| 40 | - <p class="field-hint" id="equipe-aide">{{ tr.signup_team_hint }}</p> | |
| 41 | 34 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 42 | 35 | <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label> |
| 43 | 36 | <input class="input" id="cle" name="cle" type="password" autocomplete="off" |
| @@ -49,6 +42,7 @@ | ||
| 49 | 42 | <button type="submit" class="btn btn-primary btn-block" |
| 50 | 43 | style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button> |
| 51 | 44 | </form> |
| 45 | + <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p> | |
| 52 | 46 | <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a> |
| 53 | 47 | </main> |
| 54 | 48 | {% endblock %} |
| @@ -31,13 +31,6 @@ | |||
| 31 | <input class="input" id="password" name="password" type="password" | 31 | <input class="input" id="password" name="password" type="password" |
| 32 | autocomplete="new-password" required minlength="8" style="font-size:18px"> | 32 | autocomplete="new-password" required minlength="8" style="font-size:18px"> |
| 33 | </div> | 33 | </div> |
| 34 | - <div class="field" style="margin-bottom:var(--space-2)"> | ||
| 35 | - <label for="equipe" style="font-size:18px">{{ tr.signup_team_label }}</label> | ||
| 36 | - <input class="input" id="equipe" name="equipe" value="{{ team }}" | ||
| 37 | - autocapitalize="none" autocorrect="off" required | ||
| 38 | - aria-describedby="equipe-aide" style="font-size:18px"> | ||
| 39 | - </div> | ||
| 40 | - <p class="field-hint" id="equipe-aide">{{ tr.signup_team_hint }}</p> | ||
| 41 | <div class="field" style="margin-bottom:var(--space-2)"> | 34 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 42 | <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label> | 35 | <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label> |
| 43 | <input class="input" id="cle" name="cle" type="password" autocomplete="off" | 36 | <input class="input" id="cle" name="cle" type="password" autocomplete="off" |
| @@ -49,6 +42,7 @@ | |||
| 49 | <button type="submit" class="btn btn-primary btn-block" | 42 | <button type="submit" class="btn btn-primary btn-block" |
| 50 | style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button> | 43 | style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button> |
| 51 | </form> | 44 | </form> |
| 45 | + <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p> | ||
| 52 | <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a> | 46 | <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a> |
| 53 | </main> | 47 | </main> |
| 54 | {% endblock %} | 48 | {% endblock %} |