v3 : liens d'équipe, publication sur git.open.us.org, huitième relecture

Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, 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 reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).

Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).

Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
  contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.

Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 08:12:21 +02:00
co-authored by Claude Opus 5.5
parent c3d6bf88b9
commit fd19dbf4ec
29 changed files with 938 additions and 85 deletions
+1 -1
View File
@@ -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
+6 -5
View File
@@ -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).
+1 -1
View File
@@ -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.
+16 -15
View File
@@ -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).
+2 -2
View File
@@ -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/
+81 -12
View File
@@ -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<usize>| 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<String> {
/// « 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]
@@ -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;
+7 -1
View File
@@ -34,6 +34,10 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
.await?;
let last_seen: Vec<(String, Option<String>)> =
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<String>)> = 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<AppState>, 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::<Vec<_>>(),
"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::<Vec<String>>(groups).unwrap_or_default(),
@@ -126,7 +132,7 @@ pub async fn invite(
) -> AppResult<Json<serde_json::Value>> {
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
+7 -2
View File
@@ -121,7 +121,7 @@ impl FromRequestParts<AppState> for User {
}
}
async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
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<IdentityRow>
.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)
+1 -1
View File
@@ -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).
+5
View File
@@ -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<AppState> {
.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<AppState> {
.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))
+1 -1
View File
@@ -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 {
+249
View File
@@ -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<TeamLink> {
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<String> {
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<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<PeekBody>,
) -> AppResult<Json<serde_json::Value>> {
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<String>,
}
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<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<JoinBody>,
) -> AppResult<Response> {
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<String>,
days: i64,
max_uses: i64,
}
/// The link is returned once and never stored in clear.
pub async fn create(
State(state): State<AppState>,
User(user): User,
Json(b): Json<CreateBody>,
) -> AppResult<Json<serde_json::Value>> {
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<String> = 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<AppState>,
User(user): User,
Path(hash): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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<Vec<serde_json::Value>> {
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())
}
+88
View File
@@ -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;
+1 -1
View File
@@ -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:
+9 -3
View File
@@ -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
+4 -2
View File
@@ -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.
+10 -1
View File
@@ -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
+16 -9
View File
@@ -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 |
@@ -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 |
@@ -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.
+24
View File
@@ -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);
});
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
// A link shown once, right after it was made: the server keeps only its hash.
import { t } from '$lib/i18n/index.svelte';
import { toasts } from '$lib/toast.svelte';
let { link, note }: { link: string; note: string } = $props();
async function copy() {
await navigator.clipboard.writeText(link);
toasts.show(t('access.invite.copied'));
}
</script>
<div class="link">
<p class="small">{note}</p>
<code>{link}</code>
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
</div>
<style>
.link {
display: grid;
gap: 0.5rem;
padding: 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
code {
word-break: break-all;
font-size: 0.85rem;
}
.btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
// trustinWallet — team links: one link shared with a team, one identity per person.
import { api } from '$lib/api';
import { i18n, t } from '$lib/i18n/index.svelte';
import LinkReveal from './LinkReveal.svelte';
export interface TeamLink {
id: string;
label: string;
groups: string[];
expiresAt: string;
maxUses: number;
uses: number;
}
let {
groups,
links,
act,
}: {
groups: { slug: string; name: string }[];
links: TeamLink[];
act: (fn: () => Promise<unknown>) => Promise<void>;
} = $props();
let label = $state('');
let chosen = $state<string[]>([]);
let days = $state(30);
let maxUses = $state(30);
let link = $state('');
const nameOf = (slug: string) => groups.find((g) => g.slug === slug)?.name ?? slug;
async function create(e: SubmitEvent) {
e.preventDefault();
await act(async () => {
const r = await api.post<{ link: string }>('/access/team-links', { label, groups: chosen, days, maxUses });
link = r.link;
label = '';
chosen = [];
});
}
</script>
<section>
<h2 class="eyebrow">{t('access.team')}</h2>
<p class="muted small">{t('access.team.intro')}</p>
<form class="card block" onsubmit={create}>
<label class="field"><span>{t('access.team.label')}</span><input class="input" bind:value={label} maxlength="60" required /></label>
<fieldset>
<legend>{t('access.invite.groups')}</legend>
{#each groups as g (g.slug)}
<label class="check"><input type="checkbox" value={g.slug} bind:group={chosen} /> {g.name}</label>
{/each}
</fieldset>
<div class="row">
<label class="field"><span>{t('access.team.days')}</span><input class="input" type="number" min="1" max="90" bind:value={days} required /></label>
<label class="field"><span>{t('access.team.uses')}</span><input class="input" type="number" min="1" max="500" bind:value={maxUses} required /></label>
</div>
<button class="btn primary small" type="submit">{t('access.team.create')}</button>
{#if link}<LinkReveal {link} note={t('access.team.link')} />{/if}
</form>
{#if links.length}
<ul class="list">
{#each links as l (l.id)}
<li>
<span>
<b>{l.label}</b> · {l.groups.map(nameOf).join(', ')}<br />
<span class="faint small">{t('access.team.state', { uses: l.uses, max: l.maxUses, date: i18n.date(l.expiresAt) })}</span>
</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/team-links/${l.id}`))}>{t('access.team.revoke')}</button>
</li>
{/each}
</ul>
{/if}
</section>
<style>
section {
display: grid;
gap: 0.7rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.6rem;
}
.block > .btn {
justify-self: start;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 0.35rem;
}
legend {
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
margin-bottom: 0.3rem;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.6rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.small {
font-size: 0.84rem;
}
</style>
+28
View File
@@ -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',
+28
View File
@@ -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',
+28
View File
@@ -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',
+22 -26
View File
@@ -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<string[]>([]);
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 @@
<label class="check"><input type="checkbox" bind:checked={admin} /> {t('access.invite.admin')}</label>
</fieldset>
<button class="btn primary small" type="submit">{t('access.invite.create')}</button>
{#if link}
<div class="link">
<p class="small">{t('access.invite.link')}</p>
<code>{link}</code>
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
</div>
{/if}
{#if link}<LinkReveal {link} note={t('access.invite.link')} />{/if}
</form>
{#if data.value.invitations.length}
<ul class="list">
@@ -138,6 +138,8 @@
{/if}
</section>
<TeamLinks groups={data.value.groups} links={data.value.teamLinks} {act} />
<section>
<h2 class="eyebrow">{t('access.identities')}</h2>
<ul class="list">
@@ -147,9 +149,14 @@
<b>{i.displayName}</b> · {t(`access.kind.${i.kind}`)}
{#if i.g1Member} · {t('access.member')}{/if}
{#if i.admin} · <span class="tag water">{t('access.admin')}</span>{/if}
{#if i.teamLink} · <span class="faint small">{t('access.team.via', { label: i.teamLink })}</span>{/if}
</span>
<span class="row">
{#if i.kind === 'invite'}<button class="btn quiet small" type="button" onclick={() => relink(i.id, i.displayName)}>{t('access.relink')}</button>{/if}
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</li>
{#if relinked?.id === i.id}<li class="reveal"><LinkReveal link={relinked.link} note={t('access.relink.link', { name: relinked.name })} /></li>{/if}
{/each}
</ul>
</section>
@@ -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;
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
interface Peek {
label: string;
groups: string[];
expiresAt: string;
signedInAs: string | null;
}
let phase = $state<'working' | 'invalid' | 'missing' | 'ready'>('working');
let link = $state<Peek | null>(null);
let token = '';
let name = $state('');
let error = $state('');
let busy = $state(false);
onMount(async () => {
// The token travels in the fragment: it never reaches a server log.
token = location.hash.slice(1).trim();
history.replaceState(null, '', '/equipe');
if (!/^[0-9a-f]{64}$/.test(token)) {
phase = 'missing';
return;
}
try {
link = await api.post<Peek>('/auth/team/peek', { token });
phase = 'ready';
} catch {
phase = 'invalid';
}
});
async function join(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
session.me = r.me;
await goto('/groupes', { replaceState: true });
} catch (err) {
const code = err instanceof ApiError ? err.code : 'internal';
if (code === 'invitation_invalid') phase = 'invalid';
else error = t(`error.${code}` as Key);
} finally {
busy = false;
}
}
</script>
<div class="page">
<h1>{t('team.title')}</h1>
{#if phase === 'ready' && link}
<p class="lead">{t('team.opens', { label: link.label, groups: link.groups.join(', ') })}</p>
<p class="faint small">{t('team.until', { date: i18n.date(link.expiresAt) })}</p>
<form class="card block" onsubmit={join}>
{#if link.signedInAs}
<p>{t('team.signed', { name: link.signedInAs })}</p>
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button>
{:else}
<label class="field">
<span>{t('team.name')}</span>
<input class="input" bind:value={name} minlength="2" maxlength="60" autocomplete="name" required />
</label>
<p class="faint small">{t('team.name.hint')}</p>
<button class="btn primary" type="submit" disabled={busy}>{t('team.join')}</button>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</form>
{:else}
<p class="muted" role="status">{t(`team.${phase}` as Key)}</p>
{/if}
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
.lead {
font-size: 1.08rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.7rem;
}
.block > .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
</style>