diff --git a/HANDOFF.md b/HANDOFF.md index 10a66e9..7616bcc 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,11 +7,11 @@ **Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30) ; la maquette est sur maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). Sur la branche du chantier, -pas encore en ligne : les points des septième et huitième relectures, les **liens d'équipe** +pas encore en ligne : les points des septième, huitième et neuvième relectures, les **liens d'équipe** (demandés par Yvv le 2026-10-21) et la publication vers git.open.us.org. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. -- Boucle : Rust (fmt, clippy `-D warnings`, 65 tests dont 15 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 68 tests dont 15 scénarios d'API et la publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement : santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent @@ -22,7 +22,7 @@ pas encore en ligne : les points des septième et huitième relectures, les **li - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 : ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`), toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis - `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`. La + `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`. La règle est désormais simple et explicable : **une coquille touche les lettres et les espaces, rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index 68d4685..6d3cbcd 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -236,8 +236,9 @@ fn words(s: &str) -> Vec { /// « latransmission » → « la transmission ». A boundary moved is not a typo /// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ». fn only_glued_or_split(a: &[String], b: &[String]) -> bool { - // Neither the word glued or split nor its pieces may be a negation (« ja mais »). - let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t)); + // Neither the word glued or split nor its pieces may be a negation or a + // quantity (« ja mais », « sur tout » → « surtout », « par tout » → « partout »). + let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t) || QUANTITIES.contains(t)); let (mut i, mut j, mut moves) = (0, 0, 0); while i < a.len() && j < b.len() { if a[i] == b[j] { @@ -424,7 +425,7 @@ mod tests { } #[test] - fn a_negation_is_never_cut_nor_formed() { + fn a_negation_or_a_quantity_is_never_cut_nor_formed() { // Eighth independent review, 2026-10-21: the pieces were checked, not the word. for (old, new) in [ ("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"), @@ -432,6 +433,9 @@ mod tests { ("sans retour", "san s retour"), ("rien de plus", "ri en de plus"), ("nowhere else", "now here else"), + // Ninth review: a quantity glued changes a scope. + ("Je vérifie sur tout le territoire", "Je vérifie surtout le territoire"), + ("par tout membre", "partout membre"), ] { assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}"); diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 2b90b71..9a8b818 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod assist; pub mod compose; pub mod edit; +pub mod names; pub mod threshold; pub use threshold::{FormulaParams, InertiaPreset, Tally}; diff --git a/crates/ld-engine/src/names.rs b/crates/ld-engine/src/names.rs new file mode 100644 index 0000000..e168897 --- /dev/null +++ b/crates/ld-engine/src/names.rs @@ -0,0 +1,84 @@ +//! Names people give themselves (team links): what is accepted, and the key two +//! names collide on. +//! +//! A name is shown as the author of proposals, revisions and signals, so nobody +//! may take a name already there, even dressed differently: « émile » for +//! « Émile », an accent written apart, a Cyrillic « в » or a Greek « Υ » for a +//! Latin letter, full-width « Yvv ». Hence a short alphabet — Latin letters +//! as French, English and Spanish write them, figures, space, hyphen, +//! apostrophe, dot — and a key that forgets case, accents and punctuation. + +use unicode_normalization::UnicodeNormalization; +use unicode_normalization::char::is_combining_mark; + +const MIN_NAME: usize = 2; +const MAX_NAME: usize = 60; + +#[derive(Debug, PartialEq, Eq)] +pub struct Name { + /// As shown: NFC, spaces collapsed. + pub display: String, + /// What two names collide on. + pub key: String, +} + +fn allowed(c: char) -> bool { + c.is_ascii_alphanumeric() + || (c.is_alphabetic() && matches!(c as u32, 0xC0..=0x17F)) + || matches!(c, ' ' | '-' | '\'' | '’' | '.') +} + +/// Lowercase, no accents, letters and figures only: « Émile », « EMILE », +/// « É-mile » share the key « emile ». +pub fn key(s: &str) -> String { + s.nfkd().filter(|c| !is_combining_mark(*c)).flat_map(char::to_lowercase).filter(|c| c.is_alphanumeric()).collect() +} + +/// A name a person may take, or `None`. +pub fn check(raw: &str) -> Option { + let raw = raw.trim(); + // One line, ordinary spaces only (no line or paragraph separator). + if raw.chars().any(|c| c.is_whitespace() && c != ' ') { + return None; + } + let display = raw.nfc().collect::().split(' ').filter(|w| !w.is_empty()).collect::>().join(" "); + let n = display.chars().count(); + if !(MIN_NAME..=MAX_NAME).contains(&n) || !display.chars().all(allowed) { + return None; + } + let key = key(&display); + if key.chars().count() < MIN_NAME { + return None; + } + Some(Name { display, key }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_names() { + assert_eq!(check(" Émile Zola ").map(|n| n.display), Some("Émile Zola".into())); + assert_eq!(check("Jean-Marc O'Neil").map(|n| n.key), Some("jeanmarconeil".into())); + assert_eq!(check("Núria").map(|n| n.key), Some("nuria".into())); + assert!(check("Chiara 2").is_some()); + } + + #[test] + fn the_same_name_dressed_differently_collides() { + let emile = key("Émile"); + for other in ["émile", "EMILE", "E\u{301}mile", "É-mile", "Emile."] { + assert_eq!(check(other).map(|n| n.key), Some(emile.clone()), "{other}"); + } + } + + #[test] + fn look_alikes_and_invisibles_are_refused() { + // Eighth and ninth independent reviews, 2026-10-21. + for name in ["Yvв", "Υvv", "Yvv", "Yv\u{2028}v", "Yvv\u{fff9}", "Yv\u{200b}v", "Y\nvv", "Y", "--", "Yvv 🙂"] + { + assert_eq!(check(name), None, "{name:?}"); + } + } +} diff --git a/crates/ld-server/migrations/0003_name_key.sql b/crates/ld-server/migrations/0003_name_key.sql new file mode 100644 index 0000000..c0f82ef --- /dev/null +++ b/crates/ld-server/migrations/0003_name_key.sql @@ -0,0 +1,5 @@ +-- The key a name collides on (ld_engine::names), set for the names people give +-- themselves through a team link: two people never share a name. Additive only. + +ALTER TABLE identities ADD COLUMN name_key TEXT; +CREATE UNIQUE INDEX identities_name_key ON identities(name_key) WHERE name_key IS NOT NULL; diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs index 468cdd7..d46b943 100644 --- a/crates/ld-server/src/access.rs +++ b/crates/ld-server/src/access.rs @@ -132,7 +132,11 @@ pub async fn invite( ) -> AppResult> { admin_only(&user)?; let label = b.label.trim(); - if label.is_empty() || label.chars().count() > 60 || ld_engine::edit::has_hidden_controls(label) { + if label.is_empty() + || label.chars().count() > 60 + || label.chars().any(char::is_control) + || ld_engine::edit::has_hidden_controls(label) + { return Err(AppError::Invalid("label")); } // A new link for an existing person is for invitation identities only: a link diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index dfd90b0..d582a43 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -423,9 +423,8 @@ pub async fn submit( if p.status != "draft" { return Err(AppError::Conflict("not_draft")); } - if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) { - return Err(AppError::Invalid("hidden_characters")); - } + // Drafts written under older rules are checked again before the vote opens. + check_text(&p.title, &p.body, p.kind == "text")?; let now = util::now(); sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?") .bind(&now) diff --git a/crates/ld-server/src/team.rs b/crates/ld-server/src/team.rs index 94a890c..6e961f1 100644 --- a/crates/ld-server/src/team.rs +++ b/crates/ld-server/src/team.rs @@ -2,7 +2,8 @@ //! for a number of openings. Each opening asks the person's name and makes //! **their own** invited identity, never an administrator: one person, one //! voice, and access management stays with trustinWallet's administrators. -//! Someone already signed in keeps their identity and receives the groups. +//! Someone already signed in keeps their identity and receives the groups; it +//! takes a place only if it adds a group. //! //! Limit, assumed: a person who opens the link twice under two names has two //! identities. Where invited identities vote (the test group), administrators @@ -16,13 +17,13 @@ use axum::Json; use axum::extract::{Path, State}; use axum::http::header::SET_COOKIE; use axum::response::{IntoResponse, Response}; -use ld_engine::edit; +use ld_engine::{edit, names}; use serde::Deserialize; use serde_json::json; const MAX_DAYS: i64 = 90; const MAX_USES: i64 = 500; -const MAX_NAME: usize = 60; +const MAX_LABEL: usize = 60; pub fn link(state: &AppState, token: &str) -> String { format!("{}/equipe#{token}", state.cfg.public_url) @@ -91,37 +92,44 @@ pub struct JoinBody { name: Option, } -fn check_name(raw: &str) -> AppResult<&str> { - let name = raw.trim(); - let n = name.chars().count(); - if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) { - return Err(AppError::Invalid("name")); +/// Nobody joins under the name of someone already there, however it is dressed +/// (case, accents, punctuation): compared on `names::key`. The unique index on +/// `name_key` settles two openings at the same instant. +async fn free_name(state: &AppState, raw: &str) -> AppResult { + let name = names::check(raw).ok_or(AppError::Invalid("name"))?; + let taken: Vec = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?; + if taken.iter().any(|t| names::key(t) == name.key) { + return Err(AppError::Conflict("name_taken")); } Ok(name) } +/// The groups of the link this identity does not have yet. +async fn missing_groups(state: &AppState, identity_id: &str, groups: &[String]) -> AppResult { + let mine: Vec = sqlx::query_scalar("SELECT group_slug FROM memberships WHERE identity_id = ?") + .bind(identity_id) + .fetch_all(&state.db) + .await?; + Ok(groups.iter().filter(|g| !mine.contains(g)).count()) +} + pub async fn join( State(state): State, Viewer(viewer): Viewer, Json(b): Json, ) -> AppResult { let l = open_link(&state, &b.token).await?; + let groups = slugs(&l.groups); // Checked before an opening is counted. let name = match &viewer { - Some(_) => None, - None => { - let name = check_name(b.name.as_deref().unwrap_or_default())?; - // Nobody joins under the name of someone already there. - let taken: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE display_name = ? COLLATE NOCASE") - .bind(name) - .fetch_one(&state.db) - .await?; - if taken > 0 { - return Err(AppError::Conflict("name_taken")); + Some(me) => { + // Back with the session already open: nothing to add, no place taken. + if missing_groups(&state, &me.id, &groups).await? == 0 { + return Ok(Json(json!({ "me": me })).into_response()); } - Some(name.to_owned()) + None } + None => Some(free_name(&state, b.name.as_deref().unwrap_or_default()).await?), }; // Count the opening first: of two at the last place, one only goes on. let counted = sqlx::query( @@ -139,22 +147,34 @@ pub async fn join( (Some(me), _) => (me.id, None), (None, Some(name)) => { let id = format!("inv:{}", util::id()); - sqlx::query( - "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link) - VALUES (?, 'invite', ?, 0, 0, ?, ?)", + let inserted = sqlx::query( + "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key) + VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)", ) .bind(&id) - .bind(&name) + .bind(&name.display) .bind(util::now()) .bind(&l.token_hash) + .bind(&name.key) .execute(&state.db) - .await?; + .await; + if let Err(sqlx::Error::Database(e)) = &inserted + && e.is_unique_violation() + { + // Same name at the same instant: give the place back. + sqlx::query("UPDATE team_links SET uses = uses - 1 WHERE token_hash = ?") + .bind(&l.token_hash) + .execute(&state.db) + .await?; + return Err(AppError::Conflict("name_taken")); + } + inserted?; let session = auth::open_session(&state, &id).await?; (id, Some(session)) } (None, None) => return Err(AppError::Invalid("name")), }; - auth::grant_groups(&state, &identity_id, &slugs(&l.groups), Some(&l.created_by)).await?; + auth::grant_groups(&state, &identity_id, &groups, Some(&l.created_by)).await?; tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened"); let me = auth::load_identity(&state, &identity_id).await?; let mut res = Json(json!({ "me": me })).into_response(); @@ -185,7 +205,11 @@ pub async fn create( ) -> AppResult> { admin_only(&user)?; let label = b.label.trim(); - if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) { + if label.is_empty() + || label.chars().count() > MAX_LABEL + || label.chars().any(char::is_control) + || edit::has_hidden_controls(label) + { return Err(AppError::Invalid("label")); } if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) { diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 0179ca6..7651339 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -507,8 +507,13 @@ async fn a_team_link_makes_one_identity_per_opening() { // Nobody joins under a name already there; the refusal does not count as an opening. let mut second = admin.with(None); - let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await; - assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken"))); + for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] { + let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}"); + } + // Look-alike letters from other alphabets are not a name. + let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name"))); let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening"); @@ -522,9 +527,12 @@ async fn a_team_link_makes_one_identity_per_opening() { let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await; assert_eq!(v["signedInAs"], "Luna"); - let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await; - assert_eq!(s, StatusCode::OK, "{v}"); - assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str())); + for _ in 0..3 { + // Back again with the session open: no place taken once the groups are there. + let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str())); + } let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await; assert_eq!(s, StatusCode::OK); diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index d818f84..af8a00c 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -13,7 +13,7 @@ ou de fond), jamais contre la correction précédente : - les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves restent identiques à la lettre ; - les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux - au plus, jamais en touchant une négation (ses morceaux ou le mot entier), jamais en déplaçant + au plus, jamais en touchant une négation ou une quantité (ses morceaux ou le mot entier), jamais en déplaçant une frontière ; sauts de ligne et indentation font partie de la forme ; - le titre et le corps sont jugés chacun pour soi, puis ensemble ; - au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre @@ -40,7 +40,8 @@ chariot ne sont plus des espaces libres, les lettres latines rares font partie d La huitième (`c3d6bf8 : ok`) a laissé deux points, corrigés ensuite : une négation coupée en morceaux qui n'en sont pas (« ja mais »), des invisibles hors de la liste (trait d'union conditionnel, étiquettes) ; et deux remarques : un saut de ligne dans un titre, une liste -Markdown fusionnée par les espaces. +Markdown fusionnée par les espaces. La neuvième (`fd19dbf : ok`) : un mot de quantité collé +(« sur tout » → « surtout », « par tout » → « partout ») — corrigé. **Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence diff --git a/docs/adr/0006-acces-connect-trustinwallet.md b/docs/adr/0006-acces-connect-trustinwallet.md index 493874c..650fdfe 100644 --- a/docs/adr/0006-acces-connect-trustinwallet.md +++ b/docs/adr/0006-acces-connect-trustinwallet.md @@ -13,8 +13,12 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie - **Liens d'équipe** (Yvv, 2026-10-21 : « je vais l'utiliser plein de fois, pendant tout le dev, et la partager avec une équipe ») : un lien réutilisable jusqu'à une date (90 jours au plus) et pour un nombre de personnes (500 au plus), révocable. Chaque ouverture demande un - nom et crée **sa propre** identité d'invitation, jamais administratrice ; un nom déjà pris - est refusé ; une personne déjà connectée garde son identité et reçoit les groupes. + nom et crée **sa propre** identité d'invitation, jamais administratrice. Un nom déjà pris est + refusé, quelle que soit sa tenue (casse, accents, ponctuation : `ld_engine::names`, index + unique) ; un nom s'écrit en lettres latines, chiffres, espace, trait d'union, apostrophe ou + point (pas de sosie cyrillique, grec ou pleine chasse). Une personne déjà connectée garde son + identité et reçoit les groupes ; elle ne prend une place que si le lien lui en ajoute un ; sur + un navigateur partagé, « Ce n'est pas moi » la déconnecte. trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie libre (seule la toile vote), à surveiller dans le groupe d'essai. diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 7259183..9f1963c 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -107,9 +107,9 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu entre accents graves aussi ; - les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une - négation — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne certifier » → - « necertifier », « jamais » → « ja mais » sont du fond) —, et jamais déplacer une frontière - (« les avoir » → « le savoir » est du fond) ; + négation ou une quantité — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne + certifier » → « necertifier », « jamais » → « ja mais », « sur tout » → « surtout » sont du + fond) —, et jamais déplacer une frontière (« les avoir » → « le savoir » est du fond) ; - le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du corps), puis ensemble (deux mots au plus en tout) ; - un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index d1b2dd5..72ab90b 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -20,7 +20,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 | | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là | -| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé ; « Révoquer » ferme le lien | +| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | | M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille | ## Au-delà de la liste @@ -45,3 +45,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts | | 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts | | 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts | +| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts | diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 505c6f0..1acd089 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -175,6 +175,14 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident await expect(page).toHaveURL(/\/groupes$/); await ctx.close(); } + // A shared browser: the person signed in steps aside, the next one gives a name. + const shared = await person(browser, 'Zoé'); + await shared.goto(`/equipe#${link.split('#')[1]}`); + await expect(shared.getByText('Ta session est ouverte sous le nom Zoé', { exact: false })).toBeVisible(); + await shared.getByRole('button', { name: "Ce n'est pas moi : me déconnecter" }).click(); + await shared.getByLabel('Ton nom').fill('luna'); + await shared.getByRole('button', { name: 'Rejoindre' }).click(); + await expect(shared.getByRole('alert')).toContainText('Ce nom est déjà pris'); await yvv.reload(); await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible(); await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2); diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 8e8ac3d..7ad6412 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -66,6 +66,7 @@ export const en: Messages = { 'team.join': 'Join', 'team.signed': 'Your session is open under the name {name}: these groups are added to your identity.', 'team.add': 'Add these groups', + 'team.notme': "It's not me: sign out", 'groups.title': 'My groups', 'groups.same.key': 'Groups that use the same key', @@ -287,7 +288,7 @@ export const en: Messages = { 'access.invite.cancel': 'Cancel', 'access.invite.pending': 'Pending', 'access.team': 'Team links', - 'access.team.intro': 'A link to share with a team, until a date. Each person who opens it gives their name and receives their own identity, never the administrator role.', + 'access.team.intro': 'A link to share with a team, until a date. Each person who opens it gives their name and receives their own identity, never the administrator role. Revoking closes the link; people who already came keep their access, to be removed one by one.', 'access.team.label': 'For which team', 'access.team.days': 'Valid for how many days', 'access.team.uses': 'How many people at most', @@ -334,11 +335,11 @@ export const en: Messages = { 'error.g1_not_configured': "Ğ1 sign-in isn't active yet.", 'error.invitation_invalid': 'This invitation link is no longer valid.', 'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.', - 'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): remove them.', + 'error.hidden_characters': 'The text contains invisible characters (writing-direction override, zero-width space, soft hyphen, emoji colour selector…): remove them.', 'error.changed': 'The text has changed in the meantime: reload it, then try again.', 'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.', 'error.label': 'The name is empty or too long (60 characters at most).', - 'error.name': 'Your name has 2 to 60 characters, with no invisible character.', + 'error.name': 'Your name has 2 to 60 characters, on one line: letters, figures, spaces, hyphen, apostrophe or dot.', 'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.', 'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.', 'error.groups': 'Choose at least one group.', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index cd4dca9..bcffeb7 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -66,6 +66,7 @@ export const es: Messages = { 'team.join': 'Unirme', 'team.signed': 'Tu sesión está abierta con el nombre {name}: estos grupos se añaden a tu identidad.', 'team.add': 'Añadir estos grupos', + 'team.notme': 'No soy yo: cerrar sesión', 'groups.title': 'Mis grupos', 'groups.same.key': 'Grupos que usan la misma clave', @@ -287,7 +288,7 @@ export const es: Messages = { 'access.invite.cancel': 'Cancelar', 'access.invite.pending': 'Pendiente', 'access.team': 'Enlaces de equipo', - 'access.team.intro': 'Un enlace para compartir con un equipo, hasta una fecha. Cada persona que lo abre da su nombre y recibe su propia identidad, nunca el rol de administrador.', + 'access.team.intro': 'Un enlace para compartir con un equipo, hasta una fecha. Cada persona que lo abre da su nombre y recibe su propia identidad, nunca el rol de administrador. Revocar cierra el enlace; las personas que ya llegaron conservan su acceso, que se retira una por una.', 'access.team.label': 'Para qué equipo', 'access.team.days': 'Válido cuántos días', 'access.team.uses': 'Cuántas personas como máximo', @@ -334,11 +335,11 @@ export const es: Messages = { 'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.', 'error.invitation_invalid': 'Este enlace de invitación ya no es válido.', 'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.', - 'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): quítalos.', + 'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacio de ancho cero, guion condicional, selector de color de un emoji…): quítalos.', 'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.', 'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.', 'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).', - 'error.name': 'Tu nombre tiene de 2 a 60 caracteres, sin caracteres invisibles.', + 'error.name': 'Tu nombre tiene de 2 a 60 caracteres, en una línea: letras, cifras, espacios, guion, apóstrofo o punto.', 'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.', 'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.', 'error.groups': 'Elige al menos un grupo.', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 1fdcfdb..49dc4b7 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -69,6 +69,7 @@ export const fr = { 'team.join': 'Rejoindre', 'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.', 'team.add': 'Ajouter ces groupes', + 'team.notme': "Ce n'est pas moi : me déconnecter", 'groups.title': 'Mes groupes', 'groups.same.key': 'Groupes qui utilisent la même clé', @@ -290,7 +291,7 @@ export const fr = { 'access.invite.cancel': 'Annuler', 'access.invite.pending': 'En attente', 'access.team': "Liens d'équipe", - 'access.team.intro': "Un lien à partager avec une équipe, jusqu'à une date. Chaque personne qui l'ouvre donne son nom et reçoit sa propre identité, jamais le rôle d'administrateur.", + 'access.team.intro': "Un lien à partager avec une équipe, jusqu'à une date. Chaque personne qui l'ouvre donne son nom et reçoit sa propre identité, jamais le rôle d'administrateur. Révoquer ferme le lien ; les personnes déjà venues gardent leurs accès, à retirer une par une.", 'access.team.label': 'Pour quelle équipe', 'access.team.days': 'Valable combien de jours', 'access.team.uses': 'Combien de personnes au plus', @@ -337,11 +338,11 @@ export const fr = { 'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.", 'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.", 'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.", - 'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : retire-les.', + 'error.hidden_characters': "Le texte contient des caractères invisibles (inversion du sens d'écriture, espace de largeur nulle, trait d'union conditionnel, sélecteur de couleur d'un emoji…) : retire-les.", 'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.', 'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.', 'error.label': 'Le nom est vide ou trop long (60 signes au plus).', - 'error.name': 'Ton nom fait de 2 à 60 signes, sans caractère invisible.', + 'error.name': "Ton nom fait de 2 à 60 signes, sur une ligne : lettres, chiffres, espaces, trait d'union, apostrophe ou point.", 'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.', 'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.", 'error.groups': 'Choisis au moins un groupe.', diff --git a/web/src/routes/equipe/+page.svelte b/web/src/routes/equipe/+page.svelte index 60f4966..1cbe889 100644 --- a/web/src/routes/equipe/+page.svelte +++ b/web/src/routes/equipe/+page.svelte @@ -20,9 +20,10 @@ let error = $state(''); let busy = $state(false); - onMount(async () => { + async function read() { // The token travels in the fragment: it never reaches a server log. - token = location.hash.slice(1).trim(); + const fragment = location.hash.slice(1).trim(); + if (fragment) token = fragment; history.replaceState(null, '', '/equipe'); if (!/^[0-9a-f]{64}$/.test(token)) { phase = 'missing'; @@ -34,8 +35,22 @@ } catch { phase = 'invalid'; } + } + + onMount(() => { + void read(); + // A whole link pasted into this same tab only changes the fragment. + const again = () => void read(); + window.addEventListener('hashchange', again); + return () => window.removeEventListener('hashchange', again); }); + // A shared browser: whoever is signed in is not the person opening the link. + async function notMe() { + await session.logout(); + await read(); + } + async function join(e: SubmitEvent) { e.preventDefault(); error = ''; @@ -62,7 +77,10 @@
{#if link.signedInAs}

{t('team.signed', { name: link.signedInAs })}

- +
+ + +
{:else}