forked from yvv/decision
Compare commits
6
Commits
fa8e8dc1f0
...
744d157b01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744d157b01 | ||
|
|
850f9c46b0 | ||
|
|
8a3216037c | ||
|
|
753b52202b | ||
|
|
fd19dbf4ec | ||
|
|
c3d6bf88b9 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user