v3 : neuvième relecture — noms uniques, places justes, quantités
- Noms des liens d'équipe : comparés sans casse, accents ni ponctuation (ld_engine::names), lettres latines seulement (pas de sosie cyrillique, grec ou pleine chasse), une ligne ; index unique (migration additive 0003) contre deux ouvertures simultanées sous le même nom. - Une personne déjà connectée ne prend une place que si le lien lui ajoute un groupe ; « Ce n'est pas moi » la déconnecte sur un navigateur partagé ; un lien collé dans le même onglet est relu. - Coller ou couper un mot de quantité n'est pas une coquille (« sur tout » → « surtout », « par tout » → « partout »). - Libellés sans caractère de contrôle ; la soumission revérifie le titre. 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:
+3
-3
@@ -7,11 +7,11 @@
|
|||||||
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org
|
**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
|
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,
|
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.
|
(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/`.
|
- 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) ·
|
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 :
|
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
|
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 :
|
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
|
||||||
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
|
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
|
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,
|
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
|
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
|
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
|
||||||
|
|||||||
@@ -236,8 +236,9 @@ fn words(s: &str) -> Vec<String> {
|
|||||||
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
||||||
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
||||||
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
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 »).
|
// Neither the word glued or split nor its pieces may be a negation or a
|
||||||
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t));
|
// 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);
|
let (mut i, mut j, mut moves) = (0, 0, 0);
|
||||||
while i < a.len() && j < b.len() {
|
while i < a.len() && j < b.len() {
|
||||||
if a[i] == b[j] {
|
if a[i] == b[j] {
|
||||||
@@ -424,7 +425,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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.
|
// Eighth independent review, 2026-10-21: the pieces were checked, not the word.
|
||||||
for (old, new) in [
|
for (old, new) in [
|
||||||
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
|
("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"),
|
("sans retour", "san s retour"),
|
||||||
("rien de plus", "ri en de plus"),
|
("rien de plus", "ri en de plus"),
|
||||||
("nowhere else", "now here else"),
|
("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(old, new), EditKind::Substantive, "{old} → {new}");
|
||||||
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
|
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
pub mod assist;
|
pub mod assist;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
|
pub mod names;
|
||||||
pub mod threshold;
|
pub mod threshold;
|
||||||
|
|
||||||
pub use threshold::{FormulaParams, InertiaPreset, Tally};
|
pub use threshold::{FormulaParams, InertiaPreset, Tally};
|
||||||
|
|||||||
@@ -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<Name> {
|
||||||
|
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::<String>().split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -132,7 +132,11 @@ pub async fn invite(
|
|||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
admin_only(&user)?;
|
admin_only(&user)?;
|
||||||
let label = b.label.trim();
|
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"));
|
return Err(AppError::Invalid("label"));
|
||||||
}
|
}
|
||||||
// A new link for an existing person is for invitation identities only: a link
|
// A new link for an existing person is for invitation identities only: a link
|
||||||
|
|||||||
@@ -423,9 +423,8 @@ pub async fn submit(
|
|||||||
if p.status != "draft" {
|
if p.status != "draft" {
|
||||||
return Err(AppError::Conflict("not_draft"));
|
return Err(AppError::Conflict("not_draft"));
|
||||||
}
|
}
|
||||||
if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) {
|
// Drafts written under older rules are checked again before the vote opens.
|
||||||
return Err(AppError::Invalid("hidden_characters"));
|
check_text(&p.title, &p.body, p.kind == "text")?;
|
||||||
}
|
|
||||||
let now = util::now();
|
let now = util::now();
|
||||||
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
|
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
//! for a number of openings. Each opening asks the person's name and makes
|
//! for a number of openings. Each opening asks the person's name and makes
|
||||||
//! **their own** invited identity, never an administrator: one person, one
|
//! **their own** invited identity, never an administrator: one person, one
|
||||||
//! voice, and access management stays with trustinWallet's administrators.
|
//! 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
|
//! Limit, assumed: a person who opens the link twice under two names has two
|
||||||
//! identities. Where invited identities vote (the test group), administrators
|
//! identities. Where invited identities vote (the test group), administrators
|
||||||
@@ -16,13 +17,13 @@ use axum::Json;
|
|||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::header::SET_COOKIE;
|
use axum::http::header::SET_COOKIE;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use ld_engine::edit;
|
use ld_engine::{edit, names};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
const MAX_DAYS: i64 = 90;
|
const MAX_DAYS: i64 = 90;
|
||||||
const MAX_USES: i64 = 500;
|
const MAX_USES: i64 = 500;
|
||||||
const MAX_NAME: usize = 60;
|
const MAX_LABEL: usize = 60;
|
||||||
|
|
||||||
pub fn link(state: &AppState, token: &str) -> String {
|
pub fn link(state: &AppState, token: &str) -> String {
|
||||||
format!("{}/equipe#{token}", state.cfg.public_url)
|
format!("{}/equipe#{token}", state.cfg.public_url)
|
||||||
@@ -91,37 +92,44 @@ pub struct JoinBody {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_name(raw: &str) -> AppResult<&str> {
|
/// Nobody joins under the name of someone already there, however it is dressed
|
||||||
let name = raw.trim();
|
/// (case, accents, punctuation): compared on `names::key`. The unique index on
|
||||||
let n = name.chars().count();
|
/// `name_key` settles two openings at the same instant.
|
||||||
if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) {
|
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
||||||
return Err(AppError::Invalid("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?;
|
||||||
|
if taken.iter().any(|t| names::key(t) == name.key) {
|
||||||
|
return Err(AppError::Conflict("name_taken"));
|
||||||
}
|
}
|
||||||
Ok(name)
|
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<usize> {
|
||||||
|
let mine: Vec<String> = 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(
|
pub async fn join(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Viewer(viewer): Viewer,
|
Viewer(viewer): Viewer,
|
||||||
Json(b): Json<JoinBody>,
|
Json(b): Json<JoinBody>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let l = open_link(&state, &b.token).await?;
|
let l = open_link(&state, &b.token).await?;
|
||||||
|
let groups = slugs(&l.groups);
|
||||||
// Checked before an opening is counted.
|
// Checked before an opening is counted.
|
||||||
let name = match &viewer {
|
let name = match &viewer {
|
||||||
Some(_) => None,
|
Some(me) => {
|
||||||
None => {
|
// Back with the session already open: nothing to add, no place taken.
|
||||||
let name = check_name(b.name.as_deref().unwrap_or_default())?;
|
if missing_groups(&state, &me.id, &groups).await? == 0 {
|
||||||
// Nobody joins under the name of someone already there.
|
return Ok(Json(json!({ "me": me })).into_response());
|
||||||
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())
|
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.
|
// Count the opening first: of two at the last place, one only goes on.
|
||||||
let counted = sqlx::query(
|
let counted = sqlx::query(
|
||||||
@@ -139,22 +147,34 @@ pub async fn join(
|
|||||||
(Some(me), _) => (me.id, None),
|
(Some(me), _) => (me.id, None),
|
||||||
(None, Some(name)) => {
|
(None, Some(name)) => {
|
||||||
let id = format!("inv:{}", util::id());
|
let id = format!("inv:{}", util::id());
|
||||||
sqlx::query(
|
let inserted = sqlx::query(
|
||||||
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link)
|
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
|
||||||
VALUES (?, 'invite', ?, 0, 0, ?, ?)",
|
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&name)
|
.bind(&name.display)
|
||||||
.bind(util::now())
|
.bind(util::now())
|
||||||
|
.bind(&l.token_hash)
|
||||||
|
.bind(&name.key)
|
||||||
|
.execute(&state.db)
|
||||||
|
.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)
|
.bind(&l.token_hash)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
return Err(AppError::Conflict("name_taken"));
|
||||||
|
}
|
||||||
|
inserted?;
|
||||||
let session = auth::open_session(&state, &id).await?;
|
let session = auth::open_session(&state, &id).await?;
|
||||||
(id, Some(session))
|
(id, Some(session))
|
||||||
}
|
}
|
||||||
(None, None) => return Err(AppError::Invalid("name")),
|
(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");
|
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
|
||||||
let me = auth::load_identity(&state, &identity_id).await?;
|
let me = auth::load_identity(&state, &identity_id).await?;
|
||||||
let mut res = Json(json!({ "me": me })).into_response();
|
let mut res = Json(json!({ "me": me })).into_response();
|
||||||
@@ -185,7 +205,11 @@ pub async fn create(
|
|||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
admin_only(&user)?;
|
admin_only(&user)?;
|
||||||
let label = b.label.trim();
|
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"));
|
return Err(AppError::Invalid("label"));
|
||||||
}
|
}
|
||||||
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
|
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
|
||||||
|
|||||||
@@ -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.
|
// Nobody joins under a name already there; the refusal does not count as an opening.
|
||||||
let mut second = admin.with(None);
|
let mut second = admin.with(None);
|
||||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await;
|
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
|
||||||
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": 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;
|
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
|
||||||
assert_eq!(s, StatusCode::OK, "{v}");
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
|
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 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;
|
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
|
||||||
assert_eq!(v["signedInAs"], "Luna");
|
assert_eq!(v["signedInAs"], "Luna");
|
||||||
|
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;
|
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||||||
assert_eq!(s, StatusCode::OK, "{v}");
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||||||
|
}
|
||||||
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
||||||
assert_eq!(s, StatusCode::OK);
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
|
||||||
restent identiques à la lettre ;
|
restent identiques à la lettre ;
|
||||||
- les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux
|
- 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 ;
|
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 ;
|
- 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
|
- 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
|
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
|
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
|
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
|
**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
|
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
|
||||||
|
|||||||
@@ -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
|
- **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
|
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
|
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
|
nom et crée **sa propre** identité d'invitation, jamais administratrice. Un nom déjà pris est
|
||||||
est refusé ; une personne déjà connectée garde son identité et reçoit les groupes.
|
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
|
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
|
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.
|
libre (seule la toile vote), à surveiller dans le groupe d'essai.
|
||||||
|
|||||||
@@ -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
|
chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
|
||||||
entre accents graves aussi ;
|
entre accents graves aussi ;
|
||||||
- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une
|
- 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 » →
|
négation ou une quantité — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne
|
||||||
« necertifier », « jamais » → « ja mais » sont du fond) —, et jamais déplacer une frontière
|
certifier » → « necertifier », « jamais » → « ja mais », « sur tout » → « surtout » sont du
|
||||||
(« les avoir » → « le savoir » est du fond) ;
|
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
|
- 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) ;
|
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
|
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux
|
||||||
|
|||||||
@@ -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 |
|
| 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) |
|
| 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à |
|
| 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 |
|
| 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
|
## 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 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, 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, 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 |
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
|
|||||||
await expect(page).toHaveURL(/\/groupes$/);
|
await expect(page).toHaveURL(/\/groupes$/);
|
||||||
await ctx.close();
|
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 yvv.reload();
|
||||||
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
|
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
|
||||||
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const en: Messages = {
|
|||||||
'team.join': 'Join',
|
'team.join': 'Join',
|
||||||
'team.signed': 'Your session is open under the name {name}: these groups are added to your identity.',
|
'team.signed': 'Your session is open under the name {name}: these groups are added to your identity.',
|
||||||
'team.add': 'Add these groups',
|
'team.add': 'Add these groups',
|
||||||
|
'team.notme': "It's not me: sign out",
|
||||||
|
|
||||||
'groups.title': 'My groups',
|
'groups.title': 'My groups',
|
||||||
'groups.same.key': 'Groups that use the same key',
|
'groups.same.key': 'Groups that use the same key',
|
||||||
@@ -287,7 +288,7 @@ export const en: Messages = {
|
|||||||
'access.invite.cancel': 'Cancel',
|
'access.invite.cancel': 'Cancel',
|
||||||
'access.invite.pending': 'Pending',
|
'access.invite.pending': 'Pending',
|
||||||
'access.team': 'Team links',
|
'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.label': 'For which team',
|
||||||
'access.team.days': 'Valid for how many days',
|
'access.team.days': 'Valid for how many days',
|
||||||
'access.team.uses': 'How many people at most',
|
'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.g1_not_configured': "Ğ1 sign-in isn't active yet.",
|
||||||
'error.invitation_invalid': 'This invitation link is no longer valid.',
|
'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.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.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.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.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.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.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
|
||||||
'error.groups': 'Choose at least one group.',
|
'error.groups': 'Choose at least one group.',
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const es: Messages = {
|
|||||||
'team.join': 'Unirme',
|
'team.join': 'Unirme',
|
||||||
'team.signed': 'Tu sesión está abierta con el nombre {name}: estos grupos se añaden a tu identidad.',
|
'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.add': 'Añadir estos grupos',
|
||||||
|
'team.notme': 'No soy yo: cerrar sesión',
|
||||||
|
|
||||||
'groups.title': 'Mis grupos',
|
'groups.title': 'Mis grupos',
|
||||||
'groups.same.key': 'Grupos que usan la misma clave',
|
'groups.same.key': 'Grupos que usan la misma clave',
|
||||||
@@ -287,7 +288,7 @@ export const es: Messages = {
|
|||||||
'access.invite.cancel': 'Cancelar',
|
'access.invite.cancel': 'Cancelar',
|
||||||
'access.invite.pending': 'Pendiente',
|
'access.invite.pending': 'Pendiente',
|
||||||
'access.team': 'Enlaces de equipo',
|
'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.label': 'Para qué equipo',
|
||||||
'access.team.days': 'Válido cuántos días',
|
'access.team.days': 'Válido cuántos días',
|
||||||
'access.team.uses': 'Cuántas personas como máximo',
|
'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.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.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.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.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.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.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.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.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.groups': 'Elige al menos un grupo.',
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const fr = {
|
|||||||
'team.join': 'Rejoindre',
|
'team.join': 'Rejoindre',
|
||||||
'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.',
|
'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.',
|
||||||
'team.add': 'Ajouter ces groupes',
|
'team.add': 'Ajouter ces groupes',
|
||||||
|
'team.notme': "Ce n'est pas moi : me déconnecter",
|
||||||
|
|
||||||
'groups.title': 'Mes groupes',
|
'groups.title': 'Mes groupes',
|
||||||
'groups.same.key': 'Groupes qui utilisent la même clé',
|
'groups.same.key': 'Groupes qui utilisent la même clé',
|
||||||
@@ -290,7 +291,7 @@ export const fr = {
|
|||||||
'access.invite.cancel': 'Annuler',
|
'access.invite.cancel': 'Annuler',
|
||||||
'access.invite.pending': 'En attente',
|
'access.invite.pending': 'En attente',
|
||||||
'access.team': "Liens d'équipe",
|
'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.label': 'Pour quelle équipe',
|
||||||
'access.team.days': 'Valable combien de jours',
|
'access.team.days': 'Valable combien de jours',
|
||||||
'access.team.uses': 'Combien de personnes au plus',
|
'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.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
|
||||||
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
|
'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.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.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.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.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.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.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
|
||||||
'error.groups': 'Choisis au moins un groupe.',
|
'error.groups': 'Choisis au moins un groupe.',
|
||||||
|
|||||||
@@ -20,9 +20,10 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
async function read() {
|
||||||
// The token travels in the fragment: it never reaches a server log.
|
// 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');
|
history.replaceState(null, '', '/equipe');
|
||||||
if (!/^[0-9a-f]{64}$/.test(token)) {
|
if (!/^[0-9a-f]{64}$/.test(token)) {
|
||||||
phase = 'missing';
|
phase = 'missing';
|
||||||
@@ -34,8 +35,22 @@
|
|||||||
} catch {
|
} catch {
|
||||||
phase = 'invalid';
|
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) {
|
async function join(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = '';
|
||||||
@@ -62,7 +77,10 @@
|
|||||||
<form class="card block" onsubmit={join}>
|
<form class="card block" onsubmit={join}>
|
||||||
{#if link.signedInAs}
|
{#if link.signedInAs}
|
||||||
<p>{t('team.signed', { name: link.signedInAs })}</p>
|
<p>{t('team.signed', { name: link.signedInAs })}</p>
|
||||||
|
<div class="row">
|
||||||
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button>
|
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button>
|
||||||
|
<button class="btn quiet" type="button" onclick={notMe}>{t('team.notme')}</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{t('team.name')}</span>
|
<span>{t('team.name')}</span>
|
||||||
@@ -102,6 +120,11 @@
|
|||||||
.block > .btn {
|
.block > .btn {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
.small {
|
.small {
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user