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

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

Download ZIP

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.
Julien Durillon committed 2026-08-19T01:39:22+02:00 Browse files
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> {
118118 })
119119 }
120120
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+
121167 pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
122168 let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
123169 .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 {
8686 UsernameTaken,
8787 }
8888
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
9990 /// transaction (invariant « jamais d'utilisateur sans équipe »).
10091 ///
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+///
101102 /// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend
102103 /// une ligne vide au lieu d'avorter la transaction.
103104 pub async fn signup(
@@ -105,7 +106,6 @@ pub async fn signup(
105106 username: &str,
106107 password_hash: &str,
107108 lang: &str,
108- team: InitialTeam,
109109 ) -> sqlx::Result<Signup> {
110110 let user_id = Uuid::now_v7();
111111 let mut tx = pool.begin().await?;
@@ -125,21 +125,12 @@ pub async fn signup(
125125 return Ok(Signup::UsernameTaken);
126126 }
127127
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?;
143134 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
144135 .bind(team_id)
145136 .bind(user_id)
@@ -149,7 +140,7 @@ pub async fn signup(
149140 Ok(Signup::Created { user_id, team_id })
150141 }
151142
152-/// Cible d'une invitation./// Cible d'une invitation.
143+/// Cible d'une invitation./// Cible d'une invitation./// Cible d'une invitation.
153144 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
154145 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
155146 .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 rend102 /// 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 {
261261 pub toast_team_renamed: &'static str,
262262 pub toast_joined_pat: &'static str,
263263 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,
264266 pub err_team_name_empty: &'static str,
265267 pub err_last_team: &'static str,
266268 pub err_password_short: &'static str,
@@ -275,11 +277,7 @@ pub struct Tr {
275277 pub err_signup_key: &'static str,
276278 pub err_signup_username_taken: &'static str,
277279 pub err_signup_username_invalid: &'static str,
278- pub signup_team_label: &'static str,
279280 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,
283281 pub err_signup_rate_limited: &'static str,
284282 pub err_signup_too_many_attempts: &'static str,
285283 pub err_password_mismatch: &'static str,
@@ -554,11 +552,12 @@ pub static FR: Tr = Tr {
554552 toast_team_renamed: "Équipe renommée",
555553 toast_joined_pat: "Tu as rejoint {}",
556554 toast_team_left: "Équipe quittée",
555+ toast_team_left_deleted: "Équipe quittée — elle était vide, elle a été supprimée",
557556 err_team_name_empty: "Le nom d'équipe ne peut pas être vide",
558557 err_last_team: "Impossible de quitter ta dernière équipe",
559558 err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.",
560559 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.",
562561 signup_key_label: "Clé d'inscription",
563562 signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.",
564563 signup_submit: "Créer le compte",
@@ -567,11 +566,7 @@ pub static FR: Tr = Tr {
567566 err_signup_key: "Clé d'inscription invalide.",
568567 err_signup_username_taken: "Cet identifiant est déjà pris.",
569568 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.",
575570 err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.",
576571 err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.",
577572 err_password_mismatch: "La confirmation ne correspond pas.",
@@ -816,11 +811,12 @@ pub static EN: Tr = Tr {
816811 toast_team_renamed: "Team renamed",
817812 toast_joined_pat: "You joined {}",
818813 toast_team_left: "Team left",
814+ toast_team_left_deleted: "Team left — it was empty, so it was deleted",
819815 err_team_name_empty: "The team name cannot be empty",
820816 err_last_team: "You cannot leave your last team",
821817 err_password_short: "The new password must be at least 8 characters.",
822818 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.",
824820 signup_key_label: "Signup key",
825821 signup_key_hint: "Ask someone who already uses Ex Ego for it.",
826822 signup_submit: "Create the account",
@@ -829,11 +825,7 @@ pub static EN: Tr = Tr {
829825 err_signup_key: "Invalid signup key.",
830826 err_signup_username_taken: "That username is already taken.",
831827 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.",
837829 err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.",
838830 err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.",
839831 err_password_mismatch: "The confirmation does not match.",
@@ -1078,11 +1070,12 @@ pub static DE: Tr = Tr {
10781070 toast_team_renamed: "Team umbenannt",
10791071 toast_joined_pat: "Du bist {} beigetreten",
10801072 toast_team_left: "Team verlassen",
1073+ toast_team_left_deleted: "Team verlassen — es war leer und wurde gelöscht",
10811074 err_team_name_empty: "Der Teamname darf nicht leer sein",
10821075 err_last_team: "Du kannst dein letztes Team nicht verlassen",
10831076 err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
10841077 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.",
10861079 signup_key_label: "Anmeldeschlüssel",
10871080 signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.",
10881081 signup_submit: "Konto erstellen",
@@ -1091,11 +1084,7 @@ pub static DE: Tr = Tr {
10911084 err_signup_key: "Ungültiger Anmeldeschlüssel.",
10921085 err_signup_username_taken: "Dieser Benutzername ist schon vergeben.",
10931086 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.",
10991088 err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.",
11001089 err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.",
11011090 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",
@@ -1340,11 +1329,12 @@ pub static PT_BR: Tr = Tr {
13401329 toast_team_renamed: "Equipe renomeada",
13411330 toast_joined_pat: "Você entrou em {}",
13421331 toast_team_left: "Você saiu da equipe",
1332+ toast_team_left_deleted: "Você saiu da equipe — ela estava vazia e foi excluída",
13431333 err_team_name_empty: "O nome da equipe não pode ficar vazio",
13441334 err_last_team: "Você não pode sair da sua última equipe",
13451335 err_password_short: "A nova senha deve ter pelo menos 8 caracteres.",
13461336 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.",
13481338 signup_key_label: "Chave de inscrição",
13491339 signup_key_hint: "Peça a alguém que já usa o Ex Ego.",
13501340 signup_submit: "Criar a conta",
@@ -1353,11 +1343,7 @@ pub static PT_BR: Tr = Tr {
13531343 err_signup_key: "Chave de inscrição inválida.",
13541344 err_signup_username_taken: "Esse usuário já existe.",
13551345 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.",
13611347 err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.",
13621348 err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.",
13631349 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(
7878 Some("equipe-creee") => Some(p1(tr.team_created_pat, &ctx.team_name)),
7979 Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()),
8080 Some("quittee") => Some(tr.toast_team_left.to_string()),
81+ Some("quittee-supprimee") => Some(tr.toast_team_left_deleted.to_string()),
8182 Some("langue") => Some(tr.toast_lang_updated.to_string()),
8283 Some("affichage") => Some(tr.toast_theme_updated.to_string()),
8384 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;
1818
1919 use crate::auth::extractor::AuthUser;
2020 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};
2322 use crate::error::AppError;
2423 use crate::i18n::Tr;
2524 use crate::ratelimit::client_ip;
@@ -35,17 +34,15 @@ pub struct SignupPage {
3534 pub tr: &'static Tr,
3635 pub lang_code: &'static str,
3736 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.
3938 pub username: String,
40- pub team: String,
4139 }
4240
4341 #[derive(Deserialize)]
4442 pub struct SignupQuery {
4543 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é).
4745 nom: Option<String>,
48- equipe: Option<String>,
4946 }
5047
5148 /// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout
@@ -72,15 +69,11 @@ pub async fn signup_page(
7269 Some("pris") => Some(tr.err_signup_username_taken),
7370 Some("nom") => Some(tr.err_signup_username_invalid),
7471 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),
7872 Some("trop") => Some(tr.err_signup_rate_limited),
7973 Some("essais") => Some(tr.err_signup_too_many_attempts),
8074 _ => None,
8175 },
8276 username: q.nom.unwrap_or_default(),
83- team: q.equipe.unwrap_or_default(),
8477 })
8578 .into_response())
8679 }
@@ -90,8 +83,6 @@ pub struct SignupForm {
9083 username: String,
9184 password: String,
9285 cle: String,
93- /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise.
94- equipe: String,
9586 }
9687
9788 /// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de
@@ -134,29 +125,15 @@ pub async fn signup_submit(
134125 if form.password.len() < 8 {
135126 return Ok(back("court", &form));
136127 }
137- let wanted_team = form.equipe.trim().to_string();
138- if wanted_team.is_empty() {
139- return Ok(back("equipe-vide", &form));
140- }
141-
142128 // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit.
143129 if !state.signups.allows(&ip) {
144130 tracing::warn!(%ip, "inscription refusée : quota horaire atteint");
145131 return Ok(back("trop", &form));
146132 }
147133
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-
157134 let hash = password::hash(&form.password).map_err(AppError::Internal)?;
158135 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? {
160137 Signup::UsernameTaken => Ok(back("pris", &form)),
161138 Signup::Created { user_id, team_id } => {
162139 state.signups.record(&ip);
@@ -170,12 +147,12 @@ pub async fn signup_submit(
170147 }
171148 }
172149
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é).
174152 fn back(err: &str, form: &SignupForm) -> Response {
175153 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())
179156 ))
180157 .into_response()
181158 }
@@ -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 tout48 /// 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 de88 /// 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(
111111 return Ok(Redirect::to("/parametres?err=derniere").into_response());
112112 }
113113 // 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.
114116 timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?;
115- // Mes todos « en cours » repassent à faire, mes assignations disparaissent.
116117 let mut tx = state.pool.begin().await?;
118+ // Mes todos « en cours » repassent à faire, mes assignations disparaissent.
117119 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?;
118126 tx.commit().await?;
119- teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
120127 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
121128 Ok((
122129 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+ }),
124135 )
125136 .into_response())
126137 }
@@ -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 @@
3131 <input class="input" id="password" name="password" type="password"
3232 autocomplete="new-password" required minlength="8" style="font-size:18px">
3333 </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>
4134 <div class="field" style="margin-bottom:var(--space-2)">
4235 <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label>
4336 <input class="input" id="cle" name="cle" type="password" autocomplete="off"
@@ -49,6 +42,7 @@
4942 <button type="submit" class="btn btn-primary btn-block"
5043 style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button>
5144 </form>
45+ <p class="field-hint" style="margin-top:var(--space-4)">{{ tr.signup_team_hint }}</p>
5246 <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a>
5347 </main>
5448 {% 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 %}