Compare commits

...
6 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 744d157b01 v3 : publication — un envoi refusé repart, un dépôt neuf reçoit l'historique
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful
Douzième relecture (850f9c4 : ok), défauts anciens de publish.rs mis en
jeu par le nouveau dépôt :
- un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans
  droit d'écriture) repart au passage suivant, au lieu d'une file marquée
  faite avec un dépôt vide ;
- un changement de remote vers un dépôt neuf et vide lui envoie
  l'historique ;
- au démarrage, ce qui attendait repart aussitôt (le délai croissant
  pouvait atteindre 6 h).
trustinWallet montre l'état de la publication (en attente, dernier échec,
dernier commit) et offre « Publier maintenant ».

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:30:52 +02:00
YvvandClaude Opus 5.5 850f9c46b0 v3 : publication sur yvv/decision-depot-textes-reference
Le dépôt des textes créé par Yvv sur git.open.us.org (2026-10-21) :
séparé du code, pour que la clé du serveur n'écrive jamais dans
yvv/decision et que le dépôt se transfère d'un bloc à l'ouverture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:11 +02:00
YvvandClaude Opus 5.5 8a3216037c v3 : dixième relecture — mention « sur invitation », noms attendus protégés
- Tout auteur venu par invitation porte « sur invitation » à côté de son
  nom (propositions, révisions, signalements) : un nom pris avant l'arrivée
  de son titulaire Ğ1, ou une lettre sosie, ne passe pas pour un membre de
  la toile.
- Un lien d'équipe refuse aussi le nom qu'une invitation attend encore.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:06:15 +02:00
YvvandClaude Opus 5.5 753b52202b 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>
2026-10-21 08:40:04 +02:00
YvvandClaude Opus 5.5 fd19dbf4ec 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>
2026-10-21 08:12:21 +02:00
YvvandClaude Opus 5.5 c3d6bf88b9 v3 : septième relecture — collages limités, titre jugé à part, découpage contrôlé
- Mots collés ou coupés : deux au plus, jamais sur une négation
  (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond).
- Titre et corps jugés chacun pour soi, puis ensemble : le titre n'absorbe
  plus le début du corps.
- Caractères invisibles refusés aussi au découpage d'un brouillon et à la
  soumission.
- Tabulation et retour chariot ne sont plus des espaces libres ; les lettres
  latines rares (ǃ, Ʒ…) font partie de la forme.
- Le diff renvoyé après une coquille couvre le titre.

Boucle : 62 tests Rust, svelte-check, 9 vitest, e2e 12/12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:41:58 +02:00
36 changed files with 1471 additions and 115 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/decision-depot-textes-reference.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/decision-depot-textes-reference`
(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.
+22 -10
View File
@@ -4,12 +4,16 @@
## Où on en est
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer.
**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
maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). Sur la branche du chantier,
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.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 60 tests dont 14 scénarios d'API et la
- Boucle : Rust (fmt, clippy `-D warnings`, 69 tests dont 16 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
@@ -17,7 +21,8 @@
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
- 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. La
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
`fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`, `8a32160 : ok`, `850f9c4 : 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
@@ -29,12 +34,19 @@
## 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** sur git.duniter.org : `Yvv/libredecision-textes`, **public**,
vide ; y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique
s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur fait lui-même le
premier envoi.
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation),
puis créer l'**invitation de Chiara** (groupes Monnaie libre + groupe d'essai).
2. **Dépôt des textes** : `yvv/decision-depot-textes-reference` sur **git.open.us.org**, créé
par Yvv le 2026-10-21 (privé pour l'instant : les applis ne pourront le lire en brut qu'une
fois public) — un dépôt séparé du code, pour que la clé du serveur n'écrive jamais dans
`yvv/decision` et que le dépôt se transfère d'un bloc (Yvv : « 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 »). Reste à y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique s'affiche dans trustinWallet,
`/acces`, section Publication). Le serveur fait lui-même le premier envoi ; en cas d'échec il
réessaie avec un délai croissant (1 min, 2, 4… jusqu'à 6 h), repart d'une minute à chaque
redémarrage, et `/acces` → Publication montre l'état et offre « Publier maintenant ». 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). 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/
+127 -17
View File
@@ -80,23 +80,50 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
}
/// A letter of the Latin alphabet, precomposed accents included (after NFC):
/// the only letters a typo fix may touch. Any other script may hold look-alikes
/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic).
/// A letter of the Latin alphabet as French, English and Spanish write it,
/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
/// only letters a typo fix may touch. Any other letter may be a look-alike
/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display
/// (Hebrew, Arabic): it is kept as it is.
fn is_latin_letter(c: char) -> bool {
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
}
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
/// 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:
@@ -120,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');
@@ -196,20 +232,30 @@ fn words(s: &str) -> Vec<String> {
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
}
/// Same letters, words only glued or split: « latransmission » → « la
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
/// Same letters, at most two words glued or split, never a negation:
/// « 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 (mut i, mut j) = (0, 0);
// Neither the word glued or split nor its pieces may be a negation or a
// 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);
while i < a.len() && j < b.len() {
if a[i] == b[j] {
(i, j) = (i + 1, j + 1);
} else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) {
continue;
}
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]) {
} 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;
}
moves += 1;
if moves > MAX_TYPO_WORDS {
return false;
}
}
i == a.len() && j == b.len()
}
@@ -361,6 +407,65 @@ mod tests {
assert_eq!(classify(old, new), EditKind::Typographic);
}
#[test]
fn glued_or_split_words_stay_few_and_never_touch_a_negation() {
// Seventh independent review, 2026-10-21: a text in force disfigured at will.
for (old, new) in [
("à ne certifier que", "à necertifier que"),
("à ne certifier que", "àne certifier que"),
("Si non, la demande attend", "Sinon, la demande attend"),
("la transmission de ce document à chacun", "latransmission dece documentà chacun"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(
classify("de latransmission dece document", "de la transmission de ce document"),
EditKind::Typographic
);
}
#[test]
fn a_negation_or_a_quantity_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"),
// 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(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);
assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive);
assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive);
assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive);
assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic);
}
#[test]
fn accents_case_apostrophes_quotes() {
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
@@ -481,6 +586,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]
+1
View File
@@ -9,6 +9,7 @@
pub mod assist;
pub mod compose;
pub mod edit;
pub mod names;
pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally};
+84
View File
@@ -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,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;
@@ -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;
+27 -2
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",
@@ -41,10 +45,13 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
.bind(util::now())
.fetch_all(&state.db)
.await?;
let publication = json!({
let mut publication = json!({
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
"publicKey": crate::publish::public_key(&state),
});
if state.cfg.publish.is_some() {
publication["status"] = crate::publish::status(&state).await?;
}
Ok(Json(json!({
"publication": publication,
"groups": groups.iter().map(|(slug, name, policy, hidden)| json!({
@@ -55,7 +62,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 +135,11 @@ 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
|| label.chars().any(char::is_control)
|| 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
@@ -155,6 +168,18 @@ pub async fn cancel_invitation(
Ok(Json(json!({ "ok": true })))
}
/// « Publier maintenant » : a key just added, a remote just created — no need
/// to wait for the backoff.
pub async fn publish_now(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
if state.cfg.publish.is_none() {
return Err(AppError::Conflict("publication_off"));
}
crate::publish::retry_now(&state).await?;
tracing::info!(by = %user.id, "publication retried now");
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct AdminBody {
admin: bool,
+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/decision-depot-textes-reference.git`.
pub remote: String,
pub branch: String,
/// Private SSH key allowed to push there (deploy key).
+7 -2
View File
@@ -210,6 +210,9 @@ pub struct ProposalRow {
pub label: Option<String>,
pub author_id: Option<String>,
pub author_name: Option<String>,
/// The author came by invitation, not with a Ğ1 key: shown next to the name,
/// so a name taken ahead of its owner, or a look-alike, does not pass for them.
pub author_invited: Option<bool>,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
@@ -220,7 +223,7 @@ pub struct ProposalRow {
pub const PROPOSAL_SELECT: &str =
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin,
p.label, p.author_id, i.display_name AS author_name, p.revision, p.created_at, p.updated_at, p.submitted_at,
p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at,
p.entered_at, p.confirmed_at
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
@@ -466,7 +469,9 @@ fn proposal_json(
json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
"author": p.author_id.as_ref().map(|id| json!({ "id": id, "name": p.author_name })),
"author": p.author_id.as_ref().map(|id| json!({
"id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false),
})),
"mine": p.author_id.as_deref() == Some(me),
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
+6
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,9 @@ 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/publication/retry", post(access::publish_now))
.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))
+28 -12
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 {
@@ -316,15 +316,20 @@ pub async fn edit(
}
// Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning.
// Title and body are judged apart (a title never absorbs the start of the
// body) and together (two slips at most in all).
let next = format!("{title}\n{body}");
let kind = if next == format!("{}\n{}", p.title, p.body) {
let kind = if title == p.title && body == p.body {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
k => k,
}
let kinds = [
edit::classify(&voted_title, title),
edit::classify(&voted_body, body),
edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
];
// All unchanged: back to the voted text, itself a correction.
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
@@ -363,7 +368,7 @@ pub async fn edit(
if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
}
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&p.body, body) })))
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
}
EditKind::Substantive => {
if p.status == "in_force" {
@@ -418,6 +423,8 @@ pub async fn submit(
if p.status != "draft" {
return Err(AppError::Conflict("not_draft"));
}
// Drafts written under older rules are checked again before the vote opens.
check_text(&p.title, &p.body, p.kind == "text")?;
let now = util::now();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
@@ -484,6 +491,9 @@ pub async fn split(
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
return Err(AppError::Invalid("parts"));
}
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
return Err(AppError::Invalid("hidden_characters"));
}
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
.bind(parts[0])
.bind(util::now())
@@ -541,10 +551,12 @@ pub async fn detail(
title: String,
body: String,
author_name: Option<String>,
author_invited: Option<bool>,
created_at: String,
}
let revs = sqlx::query_as::<_, Rev>(
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, r.created_at
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, r.created_at
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
)
.bind(&id)
@@ -557,7 +569,8 @@ pub async fn detail(
let diff = previous
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
history.push(json!({
"revision": r.revision, "kind": r.kind, "author": r.author_name, "at": r.created_at, "diff": diff,
"revision": r.revision, "kind": r.kind, "author": r.author_name,
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
}));
previous = Some(r);
}
@@ -570,13 +583,14 @@ pub async fn detail(
reason: String,
author_id: String,
author_name: String,
author_invited: bool,
created_at: String,
lifted_at: Option<String>,
lifted_by: Option<String>,
}
let sigs = sqlx::query_as::<_, Sig>(
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
s.created_at, s.lifted_at, s.lifted_by
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
FROM signals s JOIN identities i ON i.id = s.author_id
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
)
@@ -593,7 +607,8 @@ pub async fn detail(
None => None,
};
signals.push(json!({
"id": s.id, "reason": s.reason, "author": s.author_name, "mine": s.author_id == user.id,
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
"mine": s.author_id == user.id,
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
}));
@@ -616,7 +631,8 @@ pub async fn detail(
"proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body,
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
"author": p.author_name, "mine": p.author_id.as_deref() == Some(user.id.as_str()),
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(),
+53 -8
View File
@@ -137,8 +137,9 @@ async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<Stri
}
/// The server's own clone: a cache of the remote, realigned on it before each
/// commit so a push never needs a merge.
async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
/// commit so a push never needs a merge. Returns the clone and the commit the
/// remote branch points to, if it exists.
async fn checkout(state: &AppState) -> anyhow::Result<(PathBuf, Option<String>)> {
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
let dir = state.cfg.data_dir.join("textes");
if !dir.join(".git").exists() {
@@ -148,7 +149,8 @@ async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
}
git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?;
let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?;
if has_remote_branch.is_empty() {
let remote_head = has_remote_branch.split_whitespace().next().map(str::to_owned);
if remote_head.is_none() {
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?;
} else {
// This clone is a cache: whatever a failed pass left is re-rendered from the database.
@@ -156,14 +158,15 @@ async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)])
.await?;
}
Ok(dir)
Ok((dir, remote_head))
}
async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<String>> {
let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let Some(path) = st.doc.publish_path.clone() else { return Ok(None) };
let rendered = render(state, &st).await?;
let dir = checkout(state).await?;
let (dir, remote_head) = checkout(state).await?;
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
let target = dir.join(&path);
tokio::fs::create_dir_all(&target).await?;
for (name, content) in &rendered {
@@ -176,7 +179,15 @@ async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<St
git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?;
let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?;
if staged.is_empty() {
return Ok(None);
// Nothing new — but a commit the remote never received still goes out: a
// first push refused (a key without write access), or a new, empty
// repository after a change of remote.
let Ok(head) = git(&dir, state, &["rev-parse", "--verify", "-q", "HEAD"]).await else { return Ok(None) };
if remote_head.as_deref() == Some(head.as_str()) {
return Ok(None);
}
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
return Ok(Some(head));
}
let reasons: Vec<String> =
sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id")
@@ -192,7 +203,6 @@ async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<St
}
}
git(&dir, state, &["commit", "-q", "-m", &message]).await?;
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?))
}
@@ -225,11 +235,13 @@ async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
/// On start, every published document is queued once more: a change that
/// entered into force just before a restart, or a new rendering format, reaches
/// the repository. Unchanged documents produce no commit.
/// the repository. Unchanged documents produce no commit. What was waiting
/// after failures is tried again at once (a key just added, a new remote).
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
if state.cfg.publish.is_none() {
return Ok(());
}
retry_now(state).await?;
let now = util::now();
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at)
@@ -244,6 +256,39 @@ pub async fn resync(state: &AppState) -> anyhow::Result<()> {
Ok(())
}
/// Everything still waiting goes out at the next pass, backoff forgotten.
pub async fn retry_now(state: &AppState) -> anyhow::Result<()> {
sqlx::query("UPDATE outbox SET next_at = ?, attempts = 0 WHERE done_at IS NULL")
.bind(util::now())
.execute(&state.db)
.await?;
state.publish_wake.notify_one();
Ok(())
}
/// What administrators see of the publication in trustinWallet.
pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
let pending: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?;
let error: Option<String> = sqlx::query_scalar(
"SELECT last_error FROM outbox WHERE done_at IS NULL AND last_error IS NOT NULL ORDER BY id DESC LIMIT 1",
)
.fetch_optional(&state.db)
.await?;
let last: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT done_at, commit_sha FROM outbox WHERE done_at IS NOT NULL AND commit_sha IS NOT NULL
ORDER BY done_at DESC LIMIT 1",
)
.fetch_optional(&state.db)
.await?;
Ok(json!({
"pending": pending,
"lastError": error,
"lastPublishedAt": last.as_ref().map(|(at, _)| at.clone()),
"lastCommit": last.and_then(|(_, sha)| sha),
}))
}
/// Markdown per language + JSON. Only the units in force, in order.
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
let titles = util::parse_lang(&st.doc.title);
+282
View File
@@ -0,0 +1,282 @@
//! 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; it
//! takes a place only if it adds a group.
//!
//! 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, names};
use serde::Deserialize;
use serde_json::json;
const MAX_DAYS: i64 = 90;
const MAX_USES: i64 = 500;
const MAX_LABEL: 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>,
}
/// Nobody joins under the name of someone already there, however it is dressed
/// (case, accents, punctuation): compared on `names::key`. The unique index on
/// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
// People already there, and those an invitation still waits for.
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
taken.extend(
sqlx::query_scalar::<_, String>(
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
)
.bind(util::now())
.fetch_all(&state.db)
.await?,
);
if taken.iter().any(|t| names::key(t) == name.key) {
return Err(AppError::Conflict("name_taken"));
}
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(
State(state): State<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<JoinBody>,
) -> AppResult<Response> {
let l = open_link(&state, &b.token).await?;
let groups = slugs(&l.groups);
// Checked before an opening is counted.
let name = match &viewer {
Some(me) => {
// Back with the session already open: nothing to add, no place taken.
if missing_groups(&state, &me.id, &groups).await? == 0 {
return Ok(Json(json!({ "me": me })).into_response());
}
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.
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());
let inserted = sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
)
.bind(&id)
.bind(&name.display)
.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)
.execute(&state.db)
.await?;
return Err(AppError::Conflict("name_taken"));
}
inserted?;
let session = auth::open_session(&state, &id).await?;
(id, Some(session))
}
(None, None) => return Err(AppError::Invalid("name")),
};
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");
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_LABEL
|| label.chars().any(char::is_control)
|| 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())
}
+213
View File
@@ -251,6 +251,22 @@ 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");
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": format!("{title} {head}"), "body": rest })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
// Typo fixes are measured against the voted text: slips never add up.
let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b
@@ -396,6 +412,14 @@ async fn split_a_draft() {
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" })))
.await;
let p = v["id"].as_str().expect("id").to_owned();
let (s, v) = a
.call(
"POST",
&format!("/api/proposals/{p}/split"),
Some(json!({ "parts": ["Première.", "Au \u{202e}01\u{202c}."] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
let (s, v) =
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
@@ -439,6 +463,121 @@ async fn access_management_is_for_administrators_only() {
assert_eq!(s, StatusCode::OK);
let (_, ov) = admin.call("GET", "/api/access", None).await;
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
// « Publier maintenant » is for administrators, and needs a configured publication.
let (s, _) = user.call("POST", "/api/access/publication/retry", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (s, v) = admin.call("POST", "/api/access/publication/retry", None).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off")));
}
#[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));
// What an invited person writes says so next to their name.
let essai = doc_id(&mut first, "essai").await;
let (_, doc) = first.call("GET", &format!("/api/docs/{essai}"), None).await;
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
let (_, v) = first
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes connues.", "submit": true,
})))
.await;
let (_, d) = admin.call("GET", &format!("/api/proposals/{}", v["id"].as_str().expect("id")), None).await;
assert_eq!(
(d["proposal"]["author"].as_str(), d["proposal"]["authorInvited"].as_bool()),
(Some("Luna"), Some(true))
);
assert_eq!(d["history"][0]["authorInvited"], true);
// Nobody joins under a name already there; the refusal does not count as an opening.
let mut second = admin.with(None);
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
}
// Nor under the name an invitation still waits for.
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara Rossi", "groups": ["essai"] }))).await;
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "chiara rossi" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
// Look-alike letters from other alphabets are not a name.
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
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");
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;
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]
@@ -548,3 +687,77 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
assert_eq!(pending, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives_it() {
// Tenth independent review (850f9c4), 2026-10-21: a first push refused (a key
// without write access) left the commit in the server's clone, and the next pass,
// finding nothing to commit, marked the queue done with the repository still empty.
use std::process::Command;
let dir = std::env::temp_dir().join(format!("ld-pub-retry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let bare = |name: &str| {
let path = dir.join(name);
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&path).status().expect("git").success());
path
};
let config = |remote: &std::path::Path| {
let mut cfg = Config::for_tests();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
branch: "main".into(),
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
});
cfg
};
let has_main = |remote: &std::path::Path| {
Command::new("git")
.arg("-C")
.arg(remote)
.args(["rev-parse", "--verify", "-q", "main"])
.output()
.expect("git")
.status
.success()
};
let pending = |db: &sqlx::SqlitePool| {
let db = db.clone();
async move {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&db)
.await
.expect("count")
}
};
let first = bare("first.git");
let hook = first.join("hooks/pre-receive");
std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook");
assert!(Command::new("chmod").arg("755").arg(&hook).status().expect("chmod").success());
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(config(&first), db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
ld_server::publish::pass(&state).await.expect("pass");
assert!(!has_main(&first), "the push was refused");
assert_eq!(pending(&db).await, 1, "the document still waits");
// The key gets its write access: the next start sends what waited, at once.
std::fs::remove_file(&hook).expect("unhook");
ld_server::publish::resync(&state).await.expect("resync");
ld_server::publish::pass(&state).await.expect("pass");
assert!(has_main(&first), "the commit kept in the clone went out");
assert_eq!(pending(&db).await, 0);
// A change of remote to a new, empty repository: it receives the history.
let second = bare("second.git");
let moved = AppState::new(config(&second), db.clone()).await.expect("state");
ld_server::publish::resync(&moved).await.expect("resync");
ld_server::publish::pass(&moved).await.expect("pass");
assert!(has_main(&second), "the new repository received the texts");
assert_eq!(pending(&db).await, 0);
let _ = std::fs::remove_dir_all(&dir);
}
+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/decision-depot-textes-reference.git}
LD_PUBLISH_BRANCH: main
RUST_LOG: info,sqlx=warn,tower_http=warn
volumes:
+16 -3
View File
@@ -12,12 +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 ne font que coller ou couper des mots, jamais déplacer 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 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 ;
- 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.
@@ -28,7 +32,16 @@ donne un conflit. Les brouillons s'éditent librement.
près ». Six relectures indépendantes (2026-10-21) ont trouvé, l'une après l'autre, des
coquilles qui changeaient le sens : mots retournés (« valide » → « invalide »), caractères
invisibles, sosies de chiffres, corrections enchaînées, adresse typosquattée, signe moins
devant un nombre en gras. D'où le passage d'une liste noire à cette règle.
devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La septième
(`fa8e8dc : ok`) a laissé trois points, corrigés aussitôt : mots collés ou coupés sans limite
(« 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. 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
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
+8 -2
View File
@@ -10,8 +10,14 @@ 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/decision-depot-textes-reference`, 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, et un dépôt neuf et vide reçoit
l'historique. Un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans droit
d'écriture) repart au passage suivant ; au démarrage, ce qui attendait repart aussitôt ;
trustinWallet montre l'état (en attente, dernier échec, dernier commit) et offre « Publier
maintenant ». 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.
+18 -1
View File
@@ -8,7 +8,24 @@ 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é, 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. Un nom qu'une invitation attend
est protégé aussi.
- Tout auteur venu par invitation porte la mention **« sur invitation »** à côté de son nom
(propositions, révisions, signalements) : un nom pris avant l'arrivée de son titulaire Ğ1, ou
une lettre sosie, ne passe jamais pour un membre de la toile.
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
libre (seule la toile vote), à surveiller dans le groupe d'essai.
- 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
+24 -7
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
@@ -95,19 +97,31 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u
- la **forme** du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre : ponctuation,
chiffres de toute écriture, signes, emphase, parenthèses, sosies, marques superposées, autres
alphabets) ne bouge pas ; une espace à côté d'un chiffre en fait partie (« 1 5 » n'est pas
« 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ;
« 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 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, 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 ou une quantité — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne
certifier » → « necertifier », « jamais » → « ja mais », « sur tout » → « surtout » 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
premières ni aux deux dernières lettres (préfixes « valide » → « invalide », terminaisons
« certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur
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 »).
@@ -137,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/decision-depot-textes-reference`, 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.
@@ -166,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/decision-depot-textes-reference` 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 |
@@ -182,6 +196,9 @@ Les raisons longues sont dans `docs/adr/`.
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
(pour la dépanner) — jamais pour une identité Ğ1.
- Une identité d'invitation peut porter le nom d'un membre Ğ1 qui ne s'est pas encore
connecté (la connexion Ğ1 ne vérifie pas les noms) : la mention « sur invitation » à côté de
chaque auteur qui n'est pas venu avec sa clé Ğ1 les distingue.
- Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où »,
« a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible
dans l'historique de l'article.
@@ -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é, 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 |
## Au-delà de la liste
@@ -42,3 +43,7 @@ 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 |
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
@@ -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.
+32
View File
@@ -155,3 +155,35 @@ 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();
}
// 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 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>
+6 -6
View File
@@ -25,13 +25,13 @@
} = $props();
interface Detail {
proposal: Proposal & { author: string | null };
proposal: Proposal & { author: string | null; authorInvited: boolean };
unit: { id: string; code: string; inertia: Inertia };
inForce: { id: string; title: string; body: string } | null;
diff: DiffPart[] | null;
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; at: string; diff: DiffPart[] | null }[];
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
signals: {
id: string; reason: string; author: string; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
electorate: number;
@@ -223,7 +223,7 @@
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
{#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
</p>
</header>
@@ -334,7 +334,7 @@
<ol class="history">
{#each detail.history.slice(1) as h (h.at + h.revision)}
<li>
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}</p>
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}</p>
{#if h.diff}<DiffText parts={h.diff} />{/if}
</li>
{/each}
@@ -349,7 +349,7 @@
<div class="signal" class:lifted={s.liftedAt}>
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
<p class="faint small">
{t('signal.by', { name: s.author })} · {i18n.date(s.at)}
{t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)}
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
</p>
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
+39 -1
View File
@@ -55,6 +55,18 @@ 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',
'team.notme': "It's not me: sign out",
'groups.title': 'My groups',
'groups.same.key': 'Groups that use the same key',
@@ -150,6 +162,7 @@ export const en: Messages = {
'panel.inforce': 'In force',
'panel.proposal': 'Proposal',
'panel.by': 'proposed by {name}',
'author.invited': 'by invitation',
'panel.seed.origin': 'text from the seed',
'panel.changes': 'View changes',
'panel.text': 'View text',
@@ -275,10 +288,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. 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.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',
@@ -291,6 +316,13 @@ export const en: Messages = {
'access.publication.remote': "Repository: {remote}",
'access.publication.key': "The server's deploy key. Add it with write access on this repository (Settings › Repository › Deploy keys): until then, publications wait.",
'access.publication.none': "Publishing is not configured on this server.",
'access.publication.pending.one': '{n} sending waiting',
'access.publication.pending.other': '{n} sendings waiting',
'access.publication.last': 'Last publication: {date} (commit {commit})',
'access.publication.never': 'Nothing has been published to this repository yet.',
'access.publication.error': 'Last failure: {error}',
'access.publication.now': 'Publish now',
'access.publication.queued': 'Publication started again.',
'access.denied': 'Access management is for trustinWallet administrators only.',
'soon.title': 'Coming soon',
@@ -311,9 +343,15 @@ export const en: Messages = {
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
'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.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.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, 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.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
'error.groups': 'Choose at least one group.',
'error.publication_off': 'Publication is not configured on this server.',
'error.retry': 'Try again',
'common.cancel': 'Cancel',
+39 -1
View File
@@ -55,6 +55,18 @@ 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',
'team.notme': 'No soy yo: cerrar sesión',
'groups.title': 'Mis grupos',
'groups.same.key': 'Grupos que usan la misma clave',
@@ -150,6 +162,7 @@ export const es: Messages = {
'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta',
'panel.by': 'propuesto por {name}',
'author.invited': 'por invitación',
'panel.seed.origin': 'texto procedente del seed',
'panel.changes': 'Ver los cambios',
'panel.text': 'Ver el texto',
@@ -275,10 +288,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. 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.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',
@@ -291,6 +316,13 @@ export const es: Messages = {
'access.publication.remote': "Repositorio: {remote}",
'access.publication.key': "Clave de despliegue del servidor. Añádela con permiso de escritura en este repositorio (Configuración › Repositorio › Claves de despliegue): sin ella, las publicaciones esperan.",
'access.publication.none': "La publicación no está configurada en este servidor.",
'access.publication.pending.one': '{n} envío en espera',
'access.publication.pending.other': '{n} envíos en espera',
'access.publication.last': 'Última publicación: {date} (commit {commit})',
'access.publication.never': 'Todavía no se ha publicado nada en este repositorio.',
'access.publication.error': 'Último fallo: {error}',
'access.publication.now': 'Publicar ahora',
'access.publication.queued': 'Publicación relanzada.',
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
'soon.title': 'Próximamente',
@@ -311,9 +343,15 @@ export const es: Messages = {
'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.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.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, 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.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.publication_off': 'La publicación no está configurada en este servidor.',
'error.retry': 'Reintentar',
'common.cancel': 'Cancelar',
+39 -1
View File
@@ -58,6 +58,18 @@ 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',
'team.notme': "Ce n'est pas moi : me déconnecter",
'groups.title': 'Mes groupes',
'groups.same.key': 'Groupes qui utilisent la même clé',
@@ -153,6 +165,7 @@ export const fr = {
'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition',
'panel.by': 'proposé par {name}',
'author.invited': 'sur invitation',
'panel.seed.origin': 'texte issu du seed',
'panel.changes': 'Voir les changements',
'panel.text': 'Voir le texte',
@@ -278,10 +291,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. 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.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',
@@ -294,6 +319,13 @@ export const fr = {
'access.publication.remote': "Dépôt : {remote}",
'access.publication.key': "Clé de déploiement du serveur. Ajoute-la avec le droit d'écriture sur ce dépôt (Paramètres › Dépôt › Clés de déploiement) : sans elle, les publications attendent.",
'access.publication.none': "La publication n'est pas configurée sur ce serveur.",
'access.publication.pending.one': '{n} envoi en attente',
'access.publication.pending.other': '{n} envois en attente',
'access.publication.last': 'Dernière publication : {date} (commit {commit})',
'access.publication.never': "Rien n'a encore été publié sur ce dépôt.",
'access.publication.error': 'Dernier échec : {error}',
'access.publication.now': 'Publier maintenant',
'access.publication.queued': 'Publication relancée.',
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
'soon.title': 'À venir',
@@ -314,9 +346,15 @@ export const fr = {
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
'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.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.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, 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.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
'error.groups': 'Choisis au moins un groupe.',
'error.publication_off': "La publication n'est pas configurée sur ce serveur.",
'error.retry': 'Réessayer',
'common.cancel': 'Annuler',
+1 -1
View File
@@ -50,7 +50,7 @@ export interface Proposal {
status: 'draft' | 'open' | 'in_force' | 'superseded' | 'applied' | 'withdrawn';
origin: 'seed' | 'member';
label: string | null;
author: { id: string; name: string | null } | null;
author: { id: string; name: string | null; invited: boolean } | null;
mine: boolean;
revision: number;
createdAt: string;
+46 -26
View File
@@ -2,16 +2,23 @@
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 };
publication: {
remote: string | null;
publicKey: string | null;
status?: { pending: number; lastError: string | null; lastPublishedAt: string | null; lastCommit: 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 +36,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 +62,19 @@
});
}
async function copy() {
await navigator.clipboard.writeText(link);
toasts.show(t('access.invite.copied'));
async function publishNow() {
await act(async () => {
await api.post('/access/publication/retry');
toasts.show(t('access.publication.queued'));
});
}
// 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 +136,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 +150,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 +161,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>
@@ -159,6 +178,15 @@
<div class="card block">
{#if data.value.publication.remote}
<p class="small">{t('access.publication.remote', { remote: data.value.publication.remote })}</p>
{#if data.value.publication.status}
{@const st = data.value.publication.status}
<p class="small">
{#if st.lastPublishedAt}{t('access.publication.last', { date: i18n.date(st.lastPublishedAt), commit: (st.lastCommit ?? '').slice(0, 7) })}{:else}{t('access.publication.never')}{/if}
{#if st.pending} · {i18n.plural('access.publication.pending', st.pending)}{/if}
</p>
{#if st.pending && st.lastError}<p class="small faint err">{t('access.publication.error', { error: st.lastError })}</p>{/if}
{#if st.pending}<button class="btn small" type="button" onclick={publishNow}>{t('access.publication.now')}</button>{/if}
{/if}
{#if data.value.publication.publicKey}
<p class="small muted">{t('access.publication.key')}</p>
<code class="key">{data.value.publication.publicKey}</code>
@@ -253,19 +281,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;
@@ -281,6 +298,9 @@
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
}
.err {
word-break: break-word;
}
.fb .body {
white-space: pre-wrap;
}
+135
View File
@@ -0,0 +1,135 @@
<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);
async function read() {
// The token travels in the fragment: it never reaches a server log.
const fragment = location.hash.slice(1).trim();
if (fragment) token = fragment;
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';
}
}
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) {
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>
<div class="row">
<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}
<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;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
</style>