diff --git a/.env.example b/.env.example index 81342a3..dffe1f3 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ LD_DEV_LOGIN=1 # dev local uniquement : connexion sans clé, membre LD_BOOTSTRAP_ADMIN=0 # 1 = au premier démarrage sans admin, écrit data/bootstrap-admin.txt (0600) # Publication des textes en vigueur (dépôt git lu par les applis clientes) -# LD_PUBLISH_REMOTE=git@git.duniter.org:Yvv/libredecision-textes.git +# LD_PUBLISH_REMOTE=gitea@git.open.us.org:yvv/libredecision-textes.git # LD_PUBLISH_BRANCH=main # LD_PUBLISH_KEY=data/keys/publish_ed25519 # générée au démarrage si absente # LD_PUBLISH_AUTHOR=libreDecision diff --git a/CLAUDE.md b/CLAUDE.md index b97f1b4..3616ffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ crates/ ld-server/ migrations/ # schéma SQLite — additif seulement src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations, - # dev, bootstrap) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler, + # dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler, # traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W # sur la chaîne) · seed.rs · web.rs (sert web/build) tests/api.rs # scénarios bout en bout @@ -47,10 +47,10 @@ seeds/ # acte-engagement-certification.json (trilingue, 33 articles web/src/ lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp… - routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur, + routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur, # panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/ # protocoles (à venir) -web/e2e/ # Playwright C1–C11 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 +web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ @@ -71,7 +71,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace -cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 12 contrôles +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 13 contrôles LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud @@ -97,4 +97,5 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la - **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit` (`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` — jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté. -- **Domaine** : decision.librodrome.org ; dépôt des textes `git.duniter.org/Yvv/libredecision-textes`. +- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/libredecision-textes` + (provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ec4632..377f070 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ cd web npm run check # svelte-check, zéro avertissement npm test # vitest : markdown, parité des catalogues i18n, anti-lexique npm run build -npm run e2e # Playwright : C1–C11 (bureau) + M1 (mobile), serveur jetable sur :8092 +npm run e2e # Playwright : C1–C12 (bureau) + M1 (mobile), serveur jetable sur :8092 ``` L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement. diff --git a/HANDOFF.md b/HANDOFF.md index 4a9f958..10a66e9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,14 +5,15 @@ ## Où on en est **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 part sur -maquette.decision.librodrome.org. Sur la branche du chantier, les trois points laissés par la -septième relecture sont corrigés (pas encore en ligne). +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** +(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`, 62 tests dont 14 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 65 tests dont 15 scénarios d'API et la publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · - e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement : + 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 des journaux), publication immédiate au démarrage, redémarrage sans commit superflu. - Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la @@ -21,7 +22,7 @@ septième relecture sont corrigés (pas encore en ligne). - 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). La + `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : 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 @@ -33,16 +34,16 @@ septième relecture sont corrigés (pas encore en ligne). ## Ce qui attend Yvv 1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`. -2. **Créer le dépôt des textes**, public et vide : `Yvv/libredecision-textes` sur - git.duniter.org (choix du formulaire, « là où regardent Cesium et Ğecko ») — question rouverte - le 2026-10-21 : git.open.us.org à la place ? (une ligne, `LD_PUBLISH_REMOTE` dans - `docker/app-compose.yml`). Y ajouter la **clé de déploiement** du serveur, **en écriture** (sa - partie publique s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur - réessaie toutes les 60 s et fait lui-même le premier envoi. +2. **Créer le dépôt des textes**, vide : `yvv/libredecision-textes` sur **git.open.us.org** + (Yvv, 2026-10-21 : « pour l'instant sur open.us ; on fera un dépôt pour l'ouverture, un + transfert, et le dépôt officiel pour les textes en vigueur »). Y ajouter la **clé de + déploiement** du serveur, **en écriture** (sa partie publique s'affiche dans trustinWallet, + `/acces`, section Publication). Le serveur réessaie toutes les 60 s et fait lui-même le + premier envoi. Changer de dépôt plus tard : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`. 3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un - seul usage : il crée l'identité administratrice), puis créer l'**invitation de Chiara** - (groupes Monnaie libre + groupe d'essai). Question ouverte : un « lien d'équipe » - réutilisable (une identité par ouverture, jamais l'administration) ? + seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe** + (`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une + invitation par personne. Pour soi sur un autre appareil : « Nouveau lien de connexion ». 4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`. 5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours). diff --git a/README.md b/README.md index 8a5e9eb..aeed0fb 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings cd web && npm run check && npm test && npm run build && npm run e2e ``` -Les contrôles d'usage (C1–C11, M1) sont décrits dans +Les contrôles d'usage (C1–C12, M1) sont décrits dans [`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ; les tests de Chiara en sont la version humaine. @@ -101,7 +101,7 @@ seeds/ # documents de départ (JSON trilingue), instanciés une seu web/ src/lib/ # api, session, i18n (fr source, en, es), composants src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)… - e2e/ # Playwright : C1–C11, M1 + e2e/ # Playwright : C1–C12, M1 docker/ # app.Dockerfile + app-compose.yml (sonic) .woodpecker/ # app.yml (le produit) · maquette.yml (la maquette) docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/ diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index eb80412..68d4685 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -89,17 +89,41 @@ fn is_latin_letter(c: char) -> bool { c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F) } -/// The only spaces free to change; any other (carriage return, tab, hair, -/// thin, ogham…) is kept as it is. -const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n']; +/// The only spaces free to change between words; any other (carriage return, +/// tab, hair, thin, ogham…) is kept as it is, and so are line breaks and the +/// indentation that opens a line: in Markdown they make lists and paragraphs. +const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}']; -/// Invisible format characters (zero-width, bidirectional overrides and -/// isolates, byte order mark): they can make "12" display as "21" ("Trojan -/// Source"). A text of reference never needs them; they are refused. +/// Invisible characters: the Unicode default-ignorable set (zero-width, +/// bidirectional overrides and isolates, soft hyphen, variation selectors, +/// tags, byte order mark…) and control characters other than line breaks and +/// tabs (an escape sequence rewrites a terminal). They can make "12" display as +/// "21" ("Trojan Source") or hide text in the published files. A text of +/// reference never needs them; they are refused. pub fn has_hidden_controls(s: &str) -> bool { - s.chars().any( - |c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF), - ) + s.chars().any(|c| { + (c.is_control() && !matches!(c, '\n' | '\r' | '\t')) + || matches!( + c as u32, + 0x00AD + | 0x034F + | 0x061C + | 0x115F..=0x1160 + | 0x17B4..=0x17B5 + | 0x180B..=0x180F + | 0x200B..=0x200F + | 0x202A..=0x202E + | 0x2060..=0x206F + | 0x3164 + | 0xFE00..=0xFE0F + | 0xFEFF + | 0xFFA0 + | 0xFFF0..=0xFFF8 + | 0x1BCA0..=0x1BCA3 + | 0x1D173..=0x1D17A + | 0xE0000..=0xE0FFF + ) + }) } /// Apostrophes and quotation marks of every style are one and the same sign: @@ -123,7 +147,16 @@ fn shape(s: &str) -> String { let is_figure = |i: Option| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric()); let mut out = String::new(); let mut in_word = false; + let mut line_start = true; for (i, &c) in cs.iter().enumerate() { + if c == '\n' || (line_start && c.is_whitespace()) { + // Line breaks and indentation are structure: « - a\n- b » is a list. + out.push(c); + in_word = false; + line_start = true; + continue; + } + line_start = false; if is_latin_letter(c) { if !in_word { out.push('a'); @@ -203,16 +236,17 @@ 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 { - let joined = |x: &String, y: &String| !NEGATIONS.contains(&x.as_str()) && !NEGATIONS.contains(&y.as_str()); + // 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)); let (mut i, mut j, mut moves) = (0, 0, 0); while i < a.len() && j < b.len() { if a[i] == b[j] { (i, j) = (i + 1, j + 1); continue; } - if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&b[j], &b[j + 1]) { + if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&a[i], &b[j], &b[j + 1]) { (i, j) = (i + 1, j + 2); - } else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&a[i], &a[i + 1]) { + } else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&b[j], &a[i], &a[i + 1]) { (i, j) = (i + 2, j + 1); } else { return false; @@ -389,6 +423,36 @@ mod tests { ); } + #[test] + fn a_negation_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"), + ("aucun compte", "au cun compte"), + ("sans retour", "san s retour"), + ("rien de plus", "ri en de plus"), + ("nowhere else", "now here else"), + ] { + assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); + assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}"); + } + } + + #[test] + fn line_breaks_and_indentation_are_structure() { + for (old, new) in [ + ("- une garantie\n- une autre", "- une garantie - une autre"), + ("- une garantie\n - une précision", "- une garantie\n- une précision"), + ("la\ntransmission", "la transmission"), + ] { + assert_eq!(classify(old, new), EditKind::Substantive, "{old:?} → {new:?}"); + } + assert_eq!( + classify("- une garantie\n- latransmission", "- une garantie\n- la transmission"), + EditKind::Typographic + ); + } + #[test] fn odd_spaces_and_look_alike_letters_are_kept() { assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive); @@ -518,6 +582,11 @@ mod tests { assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**")); assert!(has_hidden_controls("5\u{200b}0")); assert!(!has_hidden_controls("Je m’engage — « à 50 % ».")); + assert!(!has_hidden_controls("une ligne\nune autre\tavec tabulation")); + // The whole default-ignorable set and the control characters (eighth review). + for hidden in ["\u{ad}", "\u{180e}", "\u{fe0f}", "\u{e0041}", "\u{206e}", "\u{1b}[2J", "\u{7f}", "\u{85}"] { + assert!(has_hidden_controls(&format!("cer{hidden}tification")), "{hidden:?}"); + } } #[test] diff --git a/crates/ld-server/migrations/0002_team_links.sql b/crates/ld-server/migrations/0002_team_links.sql new file mode 100644 index 0000000..27919c7 --- /dev/null +++ b/crates/ld-server/migrations/0002_team_links.sql @@ -0,0 +1,17 @@ +-- Team links (2026-10-21): one link shared with a team until a date; each opening +-- makes its own invited identity, never an administrator. Additive only. + +CREATE TABLE team_links ( + token_hash TEXT PRIMARY KEY, + label TEXT NOT NULL, -- what the link is for, shown to whoever opens it + groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on each opening + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + max_uses INTEGER NOT NULL, + uses INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); + +-- The team link an identity came from, if any (shown to administrators). +ALTER TABLE identities ADD COLUMN team_link TEXT; diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs index 6bfa025..468cdd7 100644 --- a/crates/ld-server/src/access.rs +++ b/crates/ld-server/src/access.rs @@ -34,6 +34,10 @@ pub async fn overview(State(state): State, User(user): User) -> AppRes .await?; let last_seen: Vec<(String, Option)> = sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?; + let came_by: Vec<(String, String)> = + sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link") + .fetch_all(&state.db) + .await?; let invitations: Vec<(String, String, bool, String, String, Option)> = sqlx::query_as( "SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC", @@ -55,7 +59,9 @@ pub async fn overview(State(state): State, User(user): User) -> AppRes "identities": identities.iter().map(|i| json!({ "id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin, "lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()), + "teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()), })).collect::>(), + "teamLinks": crate::team::list(&state).await?, "invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({ "id": hash, "label": label, "admin": admin, "groups": serde_json::from_str::>(groups).unwrap_or_default(), @@ -126,7 +132,7 @@ pub async fn invite( ) -> AppResult> { admin_only(&user)?; let label = b.label.trim(); - if label.is_empty() || label.chars().count() > 60 { + if label.is_empty() || label.chars().count() > 60 || 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/auth.rs b/crates/ld-server/src/auth.rs index ef9429d..523ef73 100644 --- a/crates/ld-server/src/auth.rs +++ b/crates/ld-server/src/auth.rs @@ -121,7 +121,7 @@ impl FromRequestParts for User { } } -async fn open_session(state: &AppState, identity_id: &str) -> AppResult { +pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult { let token = util::token(); sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)") .bind(util::sha256_hex(&token)) @@ -146,7 +146,12 @@ pub async fn load_identity(state: &AppState, id: &str) -> AppResult .ok_or(AppError::NotFound) } -async fn grant_groups(state: &AppState, identity_id: &str, groups: &[String], by: Option<&str>) -> AppResult<()> { +pub(crate) async fn grant_groups( + state: &AppState, + identity_id: &str, + groups: &[String], + by: Option<&str>, +) -> AppResult<()> { for slug in groups { sqlx::query( "INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at) diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index df831bf..9a12337 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -12,7 +12,7 @@ pub struct Connect { #[derive(Clone, Debug)] pub struct Publish { - /// Remote of the texts repository, e.g. `git@git.duniter.org:Yvv/libredecision-textes.git`. + /// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/libredecision-textes.git`. pub remote: String, pub branch: String, /// Private SSH key allowed to push there (deploy key). diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 327c0ee..edecb55 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -14,6 +14,7 @@ pub mod error; pub mod proposals; pub mod publish; pub mod seed; +pub mod team; pub mod util; pub mod web; pub mod wot; @@ -96,6 +97,8 @@ pub fn api_router() -> Router { .route("/auth/g1/start", get(auth::g1_start)) .route("/auth/g1/callback", get(auth::g1_callback)) .route("/auth/invitation", post(auth::accept_invitation)) + .route("/auth/team", post(team::join)) + .route("/auth/team/peek", post(team::peek)) .route("/auth/dev", post(auth::dev_login)) .route("/auth/logout", post(auth::logout)) .route("/me", get(auth::me)) @@ -120,6 +123,8 @@ pub fn api_router() -> Router { .route("/access/groups/{slug}/members/{identity}", delete(access::revoke)) .route("/access/invitations", post(access::invite)) .route("/access/invitations/{hash}", delete(access::cancel_invitation)) + .route("/access/team-links", post(team::create)) + .route("/access/team-links/{hash}", delete(team::revoke)) .route("/access/identities/{id}/admin", post(access::set_admin)) .route("/access/feedback", get(access::feedback_list)) .route("/access/feedback/{id}", post(access::feedback_status)) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 8bfbfbe..dfd90b0 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -62,7 +62,7 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> { if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) { return Err(AppError::Invalid("hidden_characters")); } - if title.trim().is_empty() || title.chars().count() > MAX_TITLE { + if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) { return Err(AppError::Invalid("title")); } if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY { diff --git a/crates/ld-server/src/team.rs b/crates/ld-server/src/team.rs new file mode 100644 index 0000000..94a890c --- /dev/null +++ b/crates/ld-server/src/team.rs @@ -0,0 +1,249 @@ +//! Team links — one link an administrator shares with a team, until a date and +//! 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. +//! +//! Limit, assumed: a person who opens the link twice under two names has two +//! identities. Where invited identities vote (the test group), administrators +//! see in trustinWallet which link each identity came from. + +use crate::AppState; +use crate::auth::{self, IdentityRow, User, Viewer}; +use crate::error::{AppError, AppResult}; +use crate::util; +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::header::SET_COOKIE; +use axum::response::{IntoResponse, Response}; +use ld_engine::edit; +use serde::Deserialize; +use serde_json::json; + +const MAX_DAYS: i64 = 90; +const MAX_USES: i64 = 500; +const MAX_NAME: usize = 60; + +pub fn link(state: &AppState, token: &str) -> String { + format!("{}/equipe#{token}", state.cfg.public_url) +} + +#[derive(sqlx::FromRow)] +struct TeamLink { + token_hash: String, + label: String, + groups: String, + created_by: String, + expires_at: String, +} + +/// A link that still opens: not revoked, not expired, openings left. +async fn open_link(state: &AppState, token: &str) -> AppResult { + sqlx::query_as::<_, TeamLink>( + "SELECT token_hash, label, groups, created_by, expires_at FROM team_links + WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses", + ) + .bind(util::sha256_hex(token.trim())) + .bind(util::now()) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::Invalid("invitation_invalid")) +} + +fn slugs(raw: &str) -> Vec { + serde_json::from_str(raw).unwrap_or_default() +} + +#[derive(Deserialize)] +pub struct PeekBody { + token: String, +} + +/// What the link opens, before the person gives a name. +pub async fn peek( + State(state): State, + Viewer(viewer): Viewer, + Json(b): Json, +) -> AppResult> { + let l = open_link(&state, &b.token).await?; + let mut names = Vec::new(); + for slug in slugs(&l.groups) { + if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?") + .bind(&slug) + .fetch_optional(&state.db) + .await? + { + names.push(name); + } + } + Ok(Json(json!({ + "label": l.label, + "groups": names, + "expiresAt": l.expires_at, + "signedInAs": viewer.map(|v| v.display_name), + }))) +} + +#[derive(Deserialize)] +pub struct JoinBody { + token: String, + #[serde(default)] + 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")); + } + Ok(name) +} + +pub async fn join( + State(state): State, + Viewer(viewer): Viewer, + Json(b): Json, +) -> AppResult { + let l = open_link(&state, &b.token).await?; + // 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(name.to_owned()) + } + }; + // Count the opening first: of two at the last place, one only goes on. + let counted = sqlx::query( + "UPDATE team_links SET uses = uses + 1 + WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses", + ) + .bind(&l.token_hash) + .bind(util::now()) + .execute(&state.db) + .await?; + if counted.rows_affected() != 1 { + return Err(AppError::Invalid("invitation_invalid")); + } + let (identity_id, session) = match (viewer, name) { + (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, ?, ?)", + ) + .bind(&id) + .bind(&name) + .bind(util::now()) + .bind(&l.token_hash) + .execute(&state.db) + .await?; + 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?; + 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(); + if let Some(cookie) = session { + res.headers_mut().append(SET_COOKIE, cookie); + } + Ok(res) +} + +fn admin_only(user: &IdentityRow) -> AppResult<()> { + if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBody { + label: String, + groups: Vec, + days: i64, + max_uses: i64, +} + +/// The link is returned once and never stored in clear. +pub async fn create( + State(state): State, + User(user): User, + Json(b): Json, +) -> AppResult> { + admin_only(&user)?; + let label = b.label.trim(); + if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) { + return Err(AppError::Invalid("label")); + } + if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) { + return Err(AppError::Invalid("team_limits")); + } + let known: Vec = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?; + if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) { + return Err(AppError::Invalid("groups")); + } + let token = util::token(); + sqlx::query( + "INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(util::sha256_hex(&token)) + .bind(label) + .bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into())) + .bind(&user.id) + .bind(util::now()) + .bind(util::in_days(b.days)) + .bind(b.max_uses) + .execute(&state.db) + .await?; + tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created"); + Ok(Json(json!({ "link": link(&state, &token) }))) +} + +pub async fn revoke( + State(state): State, + User(user): User, + Path(hash): Path, +) -> AppResult> { + admin_only(&user)?; + sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL") + .bind(util::now()) + .bind(&hash) + .execute(&state.db) + .await?; + tracing::info!(by = %user.id, "team link revoked"); + Ok(Json(json!({ "ok": true }))) +} + +/// The links still open, for trustinWallet. +pub async fn list(state: &AppState) -> AppResult> { + let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as( + "SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links + WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC", + ) + .bind(util::now()) + .fetch_all(&state.db) + .await?; + Ok(rows + .into_iter() + .map(|(id, label, groups, expires, max_uses, uses)| { + json!({ + "id": id, "label": label, "groups": slugs(&groups), + "expiresAt": expires, "maxUses": max_uses, "uses": uses, + }) + }) + .collect()) +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index fe3ae76..0179ca6 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -251,6 +251,11 @@ async fn editing_is_not_voting() { ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); + // A title is one line. + let (s, v) = b + .call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body }))) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title"))); // The title never absorbs the start of the body. let title = e2["title"].as_str().expect("title"); let (head, rest) = body.split_once(' ').expect("two words"); @@ -460,6 +465,89 @@ async fn access_management_is_for_administrators_only() { assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara")); } +#[tokio::test] +async fn a_team_link_makes_one_identity_per_opening() { + let (mut admin, _) = Client::new().await; + admin.login("Yvv", true).await; + let mut user = admin.with(None); + user.login("Chiara", false).await; + let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max }); + let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await; + assert_eq!(s, StatusCode::FORBIDDEN, "administrators only"); + for (body, code) in [ + (team(json!([]), 30, 2), "groups"), + (team(json!(["nope"]), 30, 2), "groups"), + (team(json!(["essai"]), 0, 2), "team_limits"), + (team(json!(["essai"]), 30, 501), "team_limits"), + ] { + let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code))); + } + let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let link = v["link"].as_str().expect("link").to_owned(); + assert!(link.contains("/equipe#"), "{link}"); + let token = link.split('#').nth(1).expect("token").to_owned(); + + let mut first = admin.with(None); + let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["label"], "Équipe dev"); + assert_eq!(v["groups"].as_array().expect("g").len(), 1); + assert_eq!(v["signedInAs"], Value::Null); + let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name"))); + let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false))); + assert_eq!(v["me"]["displayName"], "Luna"); + let luna = v["me"]["id"].as_str().expect("id").to_owned(); + let (_, g) = first.call("GET", "/api/groups", None).await; + assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true)); + + // 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"))); + 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"); + // Two openings allowed: the third is refused. + let mut third = admin.with(None); + let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid"))); + + // Someone signed in keeps their identity and receives the groups. + let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await; + 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())); + let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await; + assert_eq!(s, StatusCode::OK); + + // Administrators see the links still open and where each identity came from; a link revokes. + let (_, ov) = admin.call("GET", "/api/access", None).await; + let links = ov["teamLinks"].as_array().expect("links"); + assert_eq!(links.len(), 2, "{ov}"); + assert!( + ov["identities"] + .as_array() + .expect("i") + .iter() + .any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev") + ); + let open = links.iter().find(|l| l["uses"] == 1).expect("second link"); + let (s, _) = + admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await; + assert_eq!(s, StatusCode::OK); + let mut late = admin.with(None); + let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn in_monnaie_libre_only_web_of_trust_members_vote() { let (mut admin, _) = Client::new().await; diff --git a/docker/app-compose.yml b/docker/app-compose.yml index 4d91ae8..ae0a0da 100644 --- a/docker/app-compose.yml +++ b/docker/app-compose.yml @@ -16,7 +16,7 @@ services: LD_PUBLIC_URL: https://${APP_DOMAIN:-decision.librodrome.org} LD_ADMIN_G1_IDS: ${LD_ADMIN_G1_IDS:-idty:8352} LD_BOOTSTRAP_ADMIN: "1" - LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-git@git.duniter.org:Yvv/libredecision-textes.git} + LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-gitea@git.open.us.org:yvv/libredecision-textes.git} LD_PUBLISH_BRANCH: main RUST_LOG: info,sqlx=warn,tower_http=warn volumes: diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index 8a1b38b..d818f84 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -12,14 +12,16 @@ ou de fond), jamais contre la correction précédente : comptent pour un même signe) ; - 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, saut de ligne) ne font que coller ou couper - des mots, deux au plus, jamais sur une négation, jamais en déplaçant une frontière ; +- 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 + 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 ajoutée, retirée ou deux voisines inversées ; jamais aux deux premières ni aux deux dernières lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de position) ; -- les caractères invisibles de mise en forme sont refusés partout où l'on écrit. +- les caractères invisibles (ensemble Unicode des ignorables, contrôles autres que saut de + ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne. Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ. @@ -35,6 +37,10 @@ devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La (« Si non » → « Sinon »), titre jugé avec le corps (le titre en absorbait le début), caractères invisibles non contrôlés au découpage d'un brouillon ; et deux remarques : tabulation et retour chariot ne sont plus des espaces libres, les lettres latines rares font partie de la forme. +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. **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/0005-publication-git.md b/docs/adr/0005-publication-git.md index a7ab5e0..4a36694 100644 --- a/docs/adr/0005-publication-git.md +++ b/docs/adr/0005-publication-git.md @@ -10,8 +10,10 @@ est remis une fois (rien ne se perd sur un redémarrage, un document inchangé n commit : sa date est celle du dernier changement, jamais « maintenant »). Un travailleur rend `fr.md`, `en.md`, `es.md` et `document.json` dans le clone du serveur, commit, pousse avec une clé de déploiement générée par le serveur (sa partie publique s'affiche dans trustinWallet), et réessaie avec un -délai croissant. Dépôt : `git.duniter.org/Yvv/libredecision-textes`, un dossier par groupe et -par document. Le français fait foi ; les traductions sont marquées éditoriales. +délai croissant. Dépôt : `git.open.us.org/yvv/libredecision-textes`, un dossier par groupe et +par document — provisoire (Yvv, 2026-10-21) : viendront un dépôt pour l'ouverture, par +transfert, puis le dépôt officiel pour les textes en vigueur ; changer de dépôt tient en une +ligne (`LD_PUBLISH_REMOTE`), le serveur réaligne son clone. Le français fait foi ; les traductions sont marquées éditoriales. **Coût.** La publication est asynchrone (quelques secondes, plus en cas de panne) ; l'état de la file se lit dans trustinWallet. diff --git a/docs/adr/0006-acces-connect-trustinwallet.md b/docs/adr/0006-acces-connect-trustinwallet.md index 8cafc88..493874c 100644 --- a/docs/adr/0006-acces-connect-trustinwallet.md +++ b/docs/adr/0006-acces-connect-trustinwallet.md @@ -8,7 +8,16 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie - Connexion Ğ1 : libreDecision est client OAuth2 de connect.monnaie-libre.fr (comme le forum). Le compte est la clé d'identité (`idty:N`) ; on garde le nom affiché et le statut de membre. - En attendant l'inscription du client : liens d'invitation à usage unique (14 jours), le - secret dans le fragment de l'URL (jamais dans les journaux du serveur). + secret dans le fragment de l'URL (jamais dans les journaux du serveur). Un administrateur + peut redonner un lien à une personne invitée (autre appareil, session perdue). +- **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. + 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. - Sessions : cookie HttpOnly, SameSite=Lax, Secure en https, 30 jours, jeton haché en base ; en-tête anti-CSRF sur toute écriture. - Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index e75c847..7259183 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -19,7 +19,9 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. connexion. Le menu complet à gauche : Décisions · Docs de référence · Mandats · Protocoles (seul Docs de référence est actif). 2. **Connexion Ğ1** — la même que le forum Monnaie libre (connect.monnaie-libre.fr). En attendant - l'inscription du client OAuth2, des **liens d'invitation** à usage unique. + l'inscription du client OAuth2, des **liens d'invitation** à usage unique (une personne), + et des **liens d'équipe** réutilisables jusqu'à une date (chaque ouverture crée sa propre + identité, jamais administratrice). 3. **Groupes** : avec une clé Ğ1, on voit Monnaie libre, Axiom, Librodrome et le Groupe local Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres @@ -98,14 +100,16 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u « 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; « lettre latine » s'entend de celles du français, de l'anglais et de l'espagnol (les lettres latines rares qui imitent un signe, « ǃ » ou « Ʒ », font partie de la forme) ; « espace » s'entend de - l'espace, de l'insécable, de la fine insécable et du saut de ligne (ni tabulation ni retour - chariot) ; + l'espace, de l'insécable et de la fine insécable entre deux mots ; le **saut de ligne** et + l'**indentation** qui ouvre une ligne font partie de la forme (en Markdown, ils font les + listes et les paragraphes) ; un titre tient sur une ligne ; - les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un 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 sur un mot de - négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond), et jamais - déplacer une frontière (« les avoir » → « le savoir » est du fond) ; +- 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) ; - 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 @@ -114,7 +118,10 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u un mot de négation, de quantité ou de position (ne, jamais, deux, majorité, tous, dessus…) ; - chaque correction est mesurée contre le **texte voté**, jamais contre la correction précédente : les glissements ne s'additionnent pas ; -- les caractères invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout ; +- les caractères invisibles (tout l'ensemble Unicode des caractères ignorables : inversion du + sens d'écriture, largeur nulle, trait d'union conditionnel, sélecteurs de variante, + étiquettes… ; et les caractères de contrôle autres que saut de ligne et tabulation) sont + refusés partout où l'on écrit ; - une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »). @@ -144,7 +151,7 @@ ouverte au vote. Son auteur peut la retirer. Chaque entrée en vigueur (et la publication initiale) passe par une file d'envoi : le serveur écrit `fr.md`, `en.md`, `es.md` (front matter : document, langue, source, mise à jour ; les traductions marquées éditoriales) et `document.json`, commit et pousse sur -`git.duniter.org/Yvv/libredecision-textes`, chemin `monnaie-libre/acte-engagement-certification/`. +`git.open.us.org/yvv/libredecision-textes`, chemin `monnaie-libre/acte-engagement-certification/`. Échec → nouvel essai avec délai croissant. La clé de publication est générée par le serveur ; sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture) du dépôt. Le groupe d'essai ne publie jamais. @@ -173,7 +180,7 @@ sans toucher au texte de la communauté. Chiara et Yvv en sont membres. | Stack | Rust (serveur, moteur) + Svelte (interface), pour la durée, la performance, la modularité | | Domaine | le produit à la racine `decision.librodrome.org` ; la maquette sur `maquette.decision.librodrome.org` | | Connexion | client OAuth2 de connect.monnaie-libre.fr (demande à poka) ; liens d'invitation en attendant | -| Dépôt des textes | `git.duniter.org/Yvv/libredecision-textes` (compte d'Yvv) | +| Dépôt des textes | `git.open.us.org/yvv/libredecision-textes` pour l'instant (2026-10-21) ; puis un dépôt pour l'ouverture, par transfert, et le dépôt officiel pour les textes en vigueur | | Garde-fou du vote permanent | nombre minimal de votants « pour » (C) | | Langues | fr, en, es ; le français est voté, les traductions sont éditoriales | | Chiara | testeuse et codeuse | diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 1dc26a3..d1b2dd5 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -20,6 +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 | | 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 @@ -43,3 +44,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts | | 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 | diff --git a/docs/chantiers/acte-certification/ONBOARDING.md b/docs/chantiers/acte-certification/ONBOARDING.md index b93520c..9fc42dc 100644 --- a/docs/chantiers/acte-certification/ONBOARDING.md +++ b/docs/chantiers/acte-certification/ONBOARDING.md @@ -17,7 +17,9 @@ les contradictions, l'inertie votée, la publication. Puis `CONTROLES.md` : ce q | Un compte sur **git.open.us.org**, collaboratrice de `yvv/decision` | pousser tes branches, ouvrir des demandes de fusion | Yvv | Le lien d'invitation s'ouvre **une seule fois** et expire en 14 jours ; il ouvre ta session sur -ce navigateur pour 30 jours. +ce navigateur pour 30 jours. Sur un autre appareil, demande à Yvv un nouveau lien de +connexion. Si Yvv t'envoie plutôt un **lien d'équipe** (`/equipe#…`), il te demande ton nom et +te crée ta propre identité ; ne l'ouvre qu'une fois, puis reviens par ta session. ## 3. Tester sans rien installer @@ -27,7 +29,7 @@ ce navigateur pour 30 jours. une proposition passe avec vos deux voix « pour » — la formule demande la majorité de l'électorat quand il est si petit. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis, tu proposes, tu signales ; le vote y est réservé aux membres de la toile (avec ta clé Ğ1). -3. Passe les contrôles C1–C11 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne. +3. Passe les contrôles C1–C12 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne. 4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce que tu attendais, ce qui s'est passé. La page, la langue et la taille de l'écran sont jointes. diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 3c059fb..505c6f0 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -155,3 +155,27 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click(); await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); }); + +test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => { + const yvv = await person(browser, 'Yvv', true); + await yvv.goto('/acces'); + const team = yvv.locator('section', { has: yvv.getByRole('heading', { name: "Liens d'équipe" }) }); + await team.getByLabel('Pour quelle équipe').fill('Équipe de test'); + await team.getByLabel("Groupe d'essai").check(); + await team.getByRole('button', { name: "Créer le lien d'équipe" }).click(); + const link = (await team.locator('.link code').textContent()) ?? ''; + expect(link).toContain('/equipe#'); + for (const name of ['Luna', 'Paul']) { + const ctx = await browser.newContext({ locale: 'fr-FR' }); + const page = await ctx.newPage(); + await page.goto(`/equipe#${link.split('#')[1]}`); + await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible(); + await page.getByLabel('Ton nom').fill(name); + await page.getByRole('button', { name: 'Rejoindre' }).click(); + await expect(page).toHaveURL(/\/groupes$/); + await ctx.close(); + } + 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/components/LinkReveal.svelte b/web/src/lib/components/LinkReveal.svelte new file mode 100644 index 0000000..49497cc --- /dev/null +++ b/web/src/lib/components/LinkReveal.svelte @@ -0,0 +1,38 @@ + + + + + diff --git a/web/src/lib/components/TeamLinks.svelte b/web/src/lib/components/TeamLinks.svelte new file mode 100644 index 0000000..9bdddb2 --- /dev/null +++ b/web/src/lib/components/TeamLinks.svelte @@ -0,0 +1,130 @@ + + +
+

{t('access.team')}

+

{t('access.team.intro')}

+
+ +
+ {t('access.invite.groups')} + {#each groups as g (g.slug)} + + {/each} +
+
+ + +
+ + {#if link}{/if} + + {#if links.length} +
    + {#each links as l (l.id)} +
  • + + {l.label} · {l.groups.map(nameOf).join(', ')}
    + {t('access.team.state', { uses: l.uses, max: l.maxUses, date: i18n.date(l.expiresAt) })} +
    + +
  • + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index a9dd1c3..8e8ac3d 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -55,6 +55,17 @@ export const en: Messages = { 'invite.working': 'Signing in…', 'invite.invalid': 'This invitation link is no longer valid: it has already been used or it has expired. Ask the person who sent it to you for a new link.', 'invite.missing': 'This link is incomplete. Open the whole invitation link, exactly as you received it.', + 'team.title': 'Join a team', + 'team.working': 'Reading the link…', + 'team.invalid': 'This team link is no longer valid: it has expired, it has been revoked or all its places are taken. Ask the person who sent it to you for a new link.', + 'team.missing': 'This link is incomplete. Open the whole team link, exactly as you received it.', + 'team.opens': '“{label}” opens for you: {groups}.', + 'team.until': 'Link valid until {date}.', + 'team.name': 'Your name', + 'team.name.hint': 'The name the group will see you under. One person, one identity: if you have already joined, come back in the browser where your session is open.', + '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', 'groups.title': 'My groups', 'groups.same.key': 'Groups that use the same key', @@ -275,10 +286,22 @@ export const en: Messages = { 'access.invite.copied': 'Link copied.', '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.label': 'For which team', + 'access.team.days': 'Valid for how many days', + 'access.team.uses': 'How many people at most', + 'access.team.create': 'Create the team link', + 'access.team.link': 'Team link, to share: it works until its date or its last place. It will not be shown again: copy it now.', + 'access.team.state': '{uses} / {max} people · until {date}', + 'access.team.revoke': 'Revoke', + 'access.team.via': 'came through “{label}”', 'access.identities': 'Identities', 'access.admin': 'administrator', 'access.admin.give': 'Make administrator', 'access.admin.take': 'Remove role', + 'access.relink': 'New sign-in link', + 'access.relink.link': 'Sign-in link for {name}, single use, valid for 14 days — to open their session on another device. It will not be shown again: copy it now.', 'access.kind.g1': 'Ğ1', 'access.kind.invite': 'invitation', 'access.kind.dev': 'dev', @@ -314,6 +337,11 @@ export const en: Messages = { 'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): 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_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.', 'error.retry': 'Try again', 'common.cancel': 'Cancel', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 795a7c2..cd4dca9 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -55,6 +55,17 @@ export const es: Messages = { 'invite.working': 'Conectando…', 'invite.invalid': 'Este enlace de invitación ya no es válido: ya se ha usado o ha caducado. Pide un enlace nuevo a la persona que te lo envió.', 'invite.missing': 'Este enlace está incompleto. Abre el enlace de invitación completo, tal como lo recibiste.', + 'team.title': 'Unirte a un equipo', + 'team.working': 'Leyendo el enlace…', + 'team.invalid': 'Este enlace de equipo ya no es válido: ha caducado, ha sido revocado o todas sus plazas están ocupadas. Pide un enlace nuevo a la persona que te lo envió.', + 'team.missing': 'Este enlace está incompleto. Abre el enlace de equipo completo, tal como lo recibiste.', + 'team.opens': '«{label}» te abre: {groups}.', + 'team.until': 'Enlace válido hasta el {date}.', + 'team.name': 'Tu nombre', + 'team.name.hint': 'El nombre con el que el grupo te verá. Una persona, una identidad: si ya te uniste, vuelve en el navegador donde tienes la sesión abierta.', + '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', 'groups.title': 'Mis grupos', 'groups.same.key': 'Grupos que usan la misma clave', @@ -275,10 +286,22 @@ export const es: Messages = { 'access.invite.copied': 'Enlace copiado.', '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.label': 'Para qué equipo', + 'access.team.days': 'Válido cuántos días', + 'access.team.uses': 'Cuántas personas como máximo', + 'access.team.create': 'Crear el enlace de equipo', + 'access.team.link': 'Enlace de equipo, para compartir: sirve hasta su fecha o su última plaza. No se volverá a mostrar: cópialo ahora.', + 'access.team.state': '{uses} / {max} personas · hasta el {date}', + 'access.team.revoke': 'Revocar', + 'access.team.via': 'llegó por «{label}»', 'access.identities': 'Identidades', 'access.admin': 'administrador', 'access.admin.give': 'Nombrar administrador', 'access.admin.take': 'Retirar el rol', + 'access.relink': 'Nuevo enlace de conexión', + 'access.relink.link': 'Enlace de conexión para {name}, de un solo uso, válido 14 días — para abrir su sesión en otro dispositivo. No se volverá a mostrar: cópialo ahora.', 'access.kind.g1': 'Ğ1', 'access.kind.invite': 'invitación', 'access.kind.dev': 'dev', @@ -314,6 +337,11 @@ export const es: Messages = { 'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): 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_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.', 'error.retry': 'Reintentar', 'common.cancel': 'Cancelar', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 433c593..1fdcfdb 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -58,6 +58,17 @@ export const fr = { 'invite.working': 'Connexion en cours…', 'invite.invalid': "Ce lien d'invitation n'est plus valable : il a déjà servi ou il a expiré. Demande un nouveau lien à la personne qui te l'a envoyé.", 'invite.missing': "Ce lien est incomplet. Ouvre le lien d'invitation entier, tel que tu l'as reçu.", + 'team.title': 'Rejoindre une équipe', + 'team.working': 'Lecture du lien…', + 'team.invalid': "Ce lien d'équipe n'est plus valable : il a expiré, il a été révoqué ou toutes ses places sont prises. Demande un nouveau lien à la personne qui te l'a envoyé.", + 'team.missing': "Ce lien est incomplet. Ouvre le lien d'équipe entier, tel que tu l'as reçu.", + 'team.opens': '« {label} » t’ouvre : {groups}.', + 'team.until': "Lien valable jusqu'au {date}.", + 'team.name': 'Ton nom', + 'team.name.hint': 'Le nom sous lequel le groupe te verra. Une personne, une identité : si tu as déjà rejoint, reviens dans le navigateur où ta session est ouverte.', + 'team.join': 'Rejoindre', + 'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.', + 'team.add': 'Ajouter ces groupes', 'groups.title': 'Mes groupes', 'groups.same.key': 'Groupes qui utilisent la même clé', @@ -278,10 +289,22 @@ export const fr = { 'access.invite.copied': 'Lien copié.', '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.label': 'Pour quelle équipe', + 'access.team.days': 'Valable combien de jours', + 'access.team.uses': 'Combien de personnes au plus', + 'access.team.create': "Créer le lien d'équipe", + 'access.team.link': "Lien d'équipe, à partager : il sert jusqu'à sa date ou à sa dernière place. Il ne s'affichera plus : copie-le maintenant.", + 'access.team.state': '{uses} / {max} personnes · jusqu’au {date}', + 'access.team.revoke': 'Révoquer', + 'access.team.via': 'venue par « {label} »', 'access.identities': 'Identités', 'access.admin': 'administrateur', 'access.admin.give': 'Rendre administrateur', 'access.admin.take': 'Retirer le rôle', + 'access.relink': 'Nouveau lien de connexion', + 'access.relink.link': "Lien de connexion pour {name}, à usage unique, valable 14 jours — pour ouvrir sa session sur un autre appareil. Il ne s'affichera plus : copie-le maintenant.", 'access.kind.g1': 'Ğ1', 'access.kind.invite': 'invitation', 'access.kind.dev': 'dev', @@ -317,6 +340,11 @@ export const fr = { 'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : 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_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.', 'error.retry': 'Réessayer', 'common.cancel': 'Annuler', diff --git a/web/src/routes/acces/+page.svelte b/web/src/routes/acces/+page.svelte index b8cc03c..dc855e7 100644 --- a/web/src/routes/acces/+page.svelte +++ b/web/src/routes/acces/+page.svelte @@ -2,16 +2,18 @@ import { onMount } from 'svelte'; import { api, ApiError } from '$lib/api'; import Guard from '$lib/components/Guard.svelte'; + import LinkReveal from '$lib/components/LinkReveal.svelte'; + import TeamLinks, { type TeamLink } from '$lib/components/TeamLinks.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { Remote } from '$lib/load.svelte'; import { session } from '$lib/session.svelte'; - import { toasts } from '$lib/toast.svelte'; interface Overview { publication: { remote: string | null; publicKey: string | null }; groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[]; - identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[]; + identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null }[]; invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[]; + teamLinks: TeamLink[]; } interface Feedback { feedback: { id: number; author: string | null; route: string; body: string; createdAt: string; status: 'new' | 'seen' | 'done' }[]; @@ -29,6 +31,7 @@ let groups = $state([]); let admin = $state(false); let link = $state(''); + let relinked = $state<{ id: string; name: string; link: string } | null>(null); let error = $state(''); const nameOf = (id: string) => data.value?.identities.find((i) => i.id === id)?.displayName ?? id; @@ -54,9 +57,12 @@ }); } - async function copy() { - await navigator.clipboard.writeText(link); - toasts.show(t('access.invite.copied')); + // A new sign-in link for an invited person (another device, a lost session). + async function relink(id: string, name: string) { + await act(async () => { + const r = await api.post<{ link: string }>('/access/invitations', { label: name, identityId: id }); + relinked = { id, name, link: r.link }; + }); } async function status(id: number, s: 'seen' | 'done') { @@ -118,13 +124,7 @@ - {#if link} - - {/if} + {#if link}{/if} {#if data.value.invitations.length}
    @@ -138,6 +138,8 @@ {/if} + +

    {t('access.identities')}

      @@ -147,9 +149,14 @@ {i.displayName} · {t(`access.kind.${i.kind}`)} {#if i.g1Member} · {t('access.member')}{/if} {#if i.admin} · {t('access.admin')}{/if} + {#if i.teamLink} · {t('access.team.via', { label: i.teamLink })}{/if} + + + {#if i.kind === 'invite'}{/if} + - + {#if relinked?.id === i.id}
    • {/if} {/each}
    @@ -253,19 +260,8 @@ .block > .btn { justify-self: start; } - .link { - display: grid; - gap: 0.5rem; - padding: 0.8rem; - border-radius: var(--r-input); - background: var(--water-wash); - } - .link code { - word-break: break-all; - font-size: 0.85rem; - } - .link .btn { - justify-self: start; + .list li.reveal { + display: block; } .small { font-size: 0.84rem; diff --git a/web/src/routes/equipe/+page.svelte b/web/src/routes/equipe/+page.svelte new file mode 100644 index 0000000..60f4966 --- /dev/null +++ b/web/src/routes/equipe/+page.svelte @@ -0,0 +1,112 @@ + + +
    +

    {t('team.title')}

    + {#if phase === 'ready' && link} +

    {t('team.opens', { label: link.label, groups: link.groups.join(', ') })}

    +

    {t('team.until', { date: i18n.date(link.expiresAt) })}

    +
    + {#if link.signedInAs} +

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

    + + {:else} + +

    {t('team.name.hint')}

    + + {/if} + {#if error}{/if} +
    + {:else} +

    {t(`team.${phase}` as Key)}

    + {/if} +
    + +