v3 : dixième relecture — mention « sur invitation », noms attendus protégés

- Tout auteur venu par invitation porte « 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 pas pour un membre de
  la toile.
- Un lien d'équipe refuse aussi le nom qu'une invitation attend encore.

Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 09:06:15 +02:00
co-authored by Claude Opus 5.5
parent 753b52202b
commit 8a3216037c
13 changed files with 67 additions and 17 deletions
+7 -2
View File
@@ -210,6 +210,9 @@ pub struct ProposalRow {
pub label: Option<String>,
pub author_id: Option<String>,
pub author_name: Option<String>,
/// 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<bool>,
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,
+11 -5
View File
@@ -551,10 +551,12 @@ pub async fn detail(
title: String,
body: String,
author_name: Option<String>,
author_invited: Option<bool>,
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<String>,
lifted_by: Option<String>,
}
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(),
+10 -1
View File
@@ -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<names::Name> {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
let taken: Vec<String> = 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<String> = 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"));
}
+19
View File
@@ -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")));