diff --git a/HANDOFF.md b/HANDOFF.md index 7616bcc..cbac261 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -22,7 +22,7 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture - 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`, `fd19dbf : ok`. La + `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : 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-server/src/docs.rs b/crates/ld-server/src/docs.rs index baadf6b..d9cd60f 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -210,6 +210,9 @@ pub struct ProposalRow { pub label: Option, pub author_id: Option, pub author_name: Option, + /// The author came by invitation, not with a Ğ1 key: shown next to the name, + /// so a name taken ahead of its owner, or a look-alike, does not pass for them. + pub author_invited: Option, pub revision: i64, pub created_at: String, pub updated_at: String, @@ -220,7 +223,7 @@ pub struct ProposalRow { pub const PROPOSAL_SELECT: &str = "SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin, - p.label, p.author_id, i.display_name AS author_name, p.revision, p.created_at, p.updated_at, p.submitted_at, + p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at, p.entered_at, p.confirmed_at FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; @@ -466,7 +469,9 @@ fn proposal_json( json!({ "id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target, "title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label, - "author": p.author_id.as_ref().map(|id| json!({ "id": id, "name": p.author_name })), + "author": p.author_id.as_ref().map(|id| json!({ + "id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false), + })), "mine": p.author_id.as_deref() == Some(me), "revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at, "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index d582a43..cfabc12 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -551,10 +551,12 @@ pub async fn detail( title: String, body: String, author_name: Option, + author_invited: Option, created_at: String, } let revs = sqlx::query_as::<_, Rev>( - "SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, r.created_at + "SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, + i.kind = 'invite' AS author_invited, r.created_at FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id", ) .bind(&id) @@ -567,7 +569,8 @@ pub async fn detail( let diff = previous .map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body))); history.push(json!({ - "revision": r.revision, "kind": r.kind, "author": r.author_name, "at": r.created_at, "diff": diff, + "revision": r.revision, "kind": r.kind, "author": r.author_name, + "authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, })); previous = Some(r); } @@ -580,13 +583,14 @@ pub async fn detail( reason: String, author_id: String, author_name: String, + author_invited: bool, created_at: String, lifted_at: Option, lifted_by: Option, } let sigs = sqlx::query_as::<_, Sig>( "SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name, - s.created_at, s.lifted_at, s.lifted_by + i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by FROM signals s JOIN identities i ON i.id = s.author_id WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC", ) @@ -603,7 +607,8 @@ pub async fn detail( None => None, }; signals.push(json!({ - "id": s.id, "reason": s.reason, "author": s.author_name, "mine": s.author_id == user.id, + "id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited, + "mine": s.author_id == user.id, "at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by, "other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })), })); @@ -626,7 +631,8 @@ pub async fn detail( "proposal": { "id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision, - "author": p.author_name, "mine": p.author_id.as_deref() == Some(user.id.as_str()), + "author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false), + "mine": p.author_id.as_deref() == Some(user.id.as_str()), "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", "tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(), diff --git a/crates/ld-server/src/team.rs b/crates/ld-server/src/team.rs index 6e961f1..510b9ef 100644 --- a/crates/ld-server/src/team.rs +++ b/crates/ld-server/src/team.rs @@ -97,7 +97,16 @@ pub struct JoinBody { /// `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?; + // People already there, and those an invitation still waits for. + let mut taken: Vec = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?; + taken.extend( + sqlx::query_scalar::<_, String>( + "SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?", + ) + .bind(util::now()) + .fetch_all(&state.db) + .await?, + ); if taken.iter().any(|t| names::key(t) == name.key) { return Err(AppError::Conflict("name_taken")); } diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 7651339..087c20a 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -504,6 +504,21 @@ async fn a_team_link_makes_one_identity_per_opening() { 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)); + // What an invited person writes says so next to their name. + let essai = doc_id(&mut first, "essai").await; + let (_, doc) = first.call("GET", &format!("/api/docs/{essai}"), None).await; + let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned(); + let (_, v) = first + .call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({ + "unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes connues.", "submit": true, + }))) + .await; + let (_, d) = admin.call("GET", &format!("/api/proposals/{}", v["id"].as_str().expect("id")), None).await; + assert_eq!( + (d["proposal"]["author"].as_str(), d["proposal"]["authorInvited"].as_bool()), + (Some("Luna"), Some(true)) + ); + assert_eq!(d["history"][0]["authorInvited"], true); // Nobody joins under a name already there; the refusal does not count as an opening. let mut second = admin.with(None); @@ -511,6 +526,10 @@ async fn a_team_link_makes_one_identity_per_opening() { 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}"); } + // Nor under the name an invitation still waits for. + admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara Rossi", "groups": ["essai"] }))).await; + let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "chiara rossi" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_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"))); diff --git a/docs/adr/0006-acces-connect-trustinwallet.md b/docs/adr/0006-acces-connect-trustinwallet.md index 650fdfe..c3d5f3d 100644 --- a/docs/adr/0006-acces-connect-trustinwallet.md +++ b/docs/adr/0006-acces-connect-trustinwallet.md @@ -18,7 +18,11 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie 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. + un navigateur partagé, « Ce n'est pas moi » la déconnecte. Un nom qu'une invitation attend + est protégé aussi. +- Tout auteur venu par invitation porte la mention **« sur invitation »** à côté de son nom + (propositions, révisions, signalements) : un nom pris avant l'arrivée de son titulaire Ğ1, ou + une lettre sosie, ne passe jamais pour un membre de la toile. 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 9f1963c..7970c83 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -196,6 +196,9 @@ Les raisons longues sont dans `docs/adr/`. **et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une. - Un administrateur peut créer un nouveau lien pour une identité d'invitation existante (pour la dépanner) — jamais pour une identité Ğ1. +- Une identité d'invitation peut porter le nom d'un membre Ğ1 qui ne s'est pas encore + connecté (la connexion Ğ1 ne vérifie pas les noms) : la mention « sur invitation » à côté de + chaque auteur qui n'est pas venu avec sa clé Ğ1 les distingue. - Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où », « a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible dans l'historique de l'article. diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 72ab90b..6000f93 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -46,3 +46,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 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 | +| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts | diff --git a/web/src/lib/components/VoteView.svelte b/web/src/lib/components/VoteView.svelte index 4659fa9..0608251 100644 --- a/web/src/lib/components/VoteView.svelte +++ b/web/src/lib/components/VoteView.svelte @@ -25,13 +25,13 @@ } = $props(); interface Detail { - proposal: Proposal & { author: string | null }; + proposal: Proposal & { author: string | null; authorInvited: boolean }; unit: { id: string; code: string; inertia: Inertia }; inForce: { id: string; title: string; body: string } | null; diff: DiffPart[] | null; - history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; at: string; diff: DiffPart[] | null }[]; + history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[]; signals: { - id: string; reason: string; author: string; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null; + id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null; other: { id: string; title: string; status: string; code: string | null } | null; }[]; electorate: number; @@ -223,7 +223,7 @@ {#if p.seedMinimal}{t('doc.seed')} {:else if p.confirmedAt}{t('doc.confirmed')} {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} - {#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if} + {#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}

@@ -334,7 +334,7 @@
    {#each detail.history.slice(1) as h (h.at + h.revision)}
  1. -

    {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}

    +

    {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}

    {#if h.diff}{/if}
  2. {/each} @@ -349,7 +349,7 @@

    {s.other?.title ?? ''} — {s.reason}

    - {t('signal.by', { name: s.author })} · {i18n.date(s.at)} + {t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)} {#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}

    {#if s.mine && !s.liftedAt}{/if} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 7ad6412..7e803bf 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -162,6 +162,7 @@ export const en: Messages = { 'panel.inforce': 'In force', 'panel.proposal': 'Proposal', 'panel.by': 'proposed by {name}', + 'author.invited': 'by invitation', 'panel.seed.origin': 'text from the seed', 'panel.changes': 'View changes', 'panel.text': 'View text', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index bcffeb7..2567423 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -162,6 +162,7 @@ export const es: Messages = { 'panel.inforce': 'En vigor', 'panel.proposal': 'Propuesta', 'panel.by': 'propuesto por {name}', + 'author.invited': 'por invitación', 'panel.seed.origin': 'texto procedente del seed', 'panel.changes': 'Ver los cambios', 'panel.text': 'Ver el texto', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 49dc4b7..6ce3a34 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -165,6 +165,7 @@ export const fr = { 'panel.inforce': 'En vigueur', 'panel.proposal': 'Proposition', 'panel.by': 'proposé par {name}', + 'author.invited': 'sur invitation', 'panel.seed.origin': 'texte issu du seed', 'panel.changes': 'Voir les changements', 'panel.text': 'Voir le texte', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 48bcfc7..952fdfd 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -50,7 +50,7 @@ export interface Proposal { status: 'draft' | 'open' | 'in_force' | 'superseded' | 'applied' | 'withdrawn'; origin: 'seed' | 'member'; label: string | null; - author: { id: string; name: string | null } | null; + author: { id: string; name: string | null; invited: boolean } | null; mine: boolean; revision: number; createdAt: string;