v3 : liens d'équipe, publication sur git.open.us.org, huitième relecture
Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, révocable. Chaque ouverture demande un nom
et crée sa propre identité d'invitation, jamais administratrice ; un nom
déjà pris est refusé ; une personne déjà connectée reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).
Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).
Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.
Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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/libredecision-textes.git
|
||||
# LD_PUBLISH_BRANCH=main
|
||||
# LD_PUBLISH_KEY=data/keys/publish_ed25519 # générée au démarrage si absente
|
||||
# LD_PUBLISH_AUTHOR=libreDecision
|
||||
|
||||
@@ -39,7 +39,7 @@ crates/
|
||||
ld-server/
|
||||
migrations/ # schéma SQLite — additif seulement
|
||||
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
||||
# dev, bootstrap) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
|
||||
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
|
||||
# traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W
|
||||
# sur la chaîne) · seed.rs · web.rs (sert web/build)
|
||||
tests/api.rs # scénarios bout en bout
|
||||
@@ -47,10 +47,10 @@ seeds/ # acte-engagement-certification.json (trilingue, 33 articles
|
||||
web/src/
|
||||
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
||||
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp…
|
||||
routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
||||
routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
||||
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
|
||||
# protocoles (à venir)
|
||||
web/e2e/ # Playwright C1–C11 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
||||
web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||
.woodpecker/ # app.yml · maquette.yml
|
||||
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
||||
@@ -71,7 +71,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
|
||||
```bash
|
||||
export PATH=$HOME/.cargo/bin:$PATH
|
||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 12 contrôles
|
||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 13 contrôles
|
||||
|
||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||
cd web && npm run dev # :3002 à chaud
|
||||
@@ -97,4 +97,5 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
|
||||
- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit`
|
||||
(`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` —
|
||||
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
|
||||
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.duniter.org/Yvv/libredecision-textes`.
|
||||
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/libredecision-textes`
|
||||
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ cd web
|
||||
npm run check # svelte-check, zéro avertissement
|
||||
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
|
||||
npm run build
|
||||
npm run e2e # Playwright : C1–C11 (bureau) + M1 (mobile), serveur jetable sur :8092
|
||||
npm run e2e # Playwright : C1–C12 (bureau) + M1 (mobile), serveur jetable sur :8092
|
||||
```
|
||||
|
||||
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
|
||||
|
||||
+16
-15
@@ -5,14 +5,15 @@
|
||||
## Où on en est
|
||||
|
||||
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org
|
||||
depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30). La maquette part sur
|
||||
maquette.decision.librodrome.org. Sur la branche du chantier, les trois points laissés par la
|
||||
septième relecture sont corrigés (pas encore en ligne).
|
||||
depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30) ; la maquette est sur
|
||||
maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). Sur la branche du chantier,
|
||||
pas encore en ligne : les points des septième et huitième relectures, les **liens d'équipe**
|
||||
(demandés par Yvv le 2026-10-21) et la publication vers git.open.us.org.
|
||||
|
||||
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
|
||||
- Boucle : Rust (fmt, clippy `-D warnings`, 62 tests dont 14 scénarios d'API et la
|
||||
- Boucle : Rust (fmt, clippy `-D warnings`, 65 tests dont 15 scénarios d'API et la
|
||||
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
|
||||
e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement :
|
||||
e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement :
|
||||
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
|
||||
des journaux), publication immédiate au démarrage, redémarrage sans commit superflu.
|
||||
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la
|
||||
@@ -21,7 +22,7 @@ septième relecture sont corrigés (pas encore en ligne).
|
||||
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
|
||||
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
|
||||
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
|
||||
`fa8e8dc : ok` (mis en ligne). La
|
||||
`fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`. La
|
||||
règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
|
||||
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens
|
||||
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
|
||||
@@ -33,16 +34,16 @@ septième relecture sont corrigés (pas encore en ligne).
|
||||
## Ce qui attend Yvv
|
||||
|
||||
1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`.
|
||||
2. **Créer le dépôt des textes**, public et vide : `Yvv/libredecision-textes` sur
|
||||
git.duniter.org (choix du formulaire, « là où regardent Cesium et Ğecko ») — question rouverte
|
||||
le 2026-10-21 : git.open.us.org à la place ? (une ligne, `LD_PUBLISH_REMOTE` dans
|
||||
`docker/app-compose.yml`). Y ajouter la **clé de déploiement** du serveur, **en écriture** (sa
|
||||
partie publique s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur
|
||||
réessaie toutes les 60 s et fait lui-même le premier envoi.
|
||||
2. **Créer le dépôt des textes**, vide : `yvv/libredecision-textes` sur **git.open.us.org**
|
||||
(Yvv, 2026-10-21 : « pour l'instant sur open.us ; on fera un dépôt pour l'ouverture, un
|
||||
transfert, et le dépôt officiel pour les textes en vigueur »). Y ajouter la **clé de
|
||||
déploiement** du serveur, **en écriture** (sa partie publique s'affiche dans trustinWallet,
|
||||
`/acces`, section Publication). Le serveur réessaie toutes les 60 s et fait lui-même le
|
||||
premier envoi. Changer de dépôt plus tard : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`.
|
||||
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un
|
||||
seul usage : il crée l'identité administratrice), puis créer l'**invitation de Chiara**
|
||||
(groupes Monnaie libre + groupe d'essai). Question ouverte : un « lien d'équipe »
|
||||
réutilisable (une identité par ouverture, jamais l'administration) ?
|
||||
seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe**
|
||||
(`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une
|
||||
invitation par personne. Pour soi sur un autre appareil : « Nouveau lien de connexion ».
|
||||
4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`.
|
||||
5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours).
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -89,17 +89,41 @@ fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
|
||||
}
|
||||
|
||||
/// The only spaces free to change; any other (carriage return, tab, hair,
|
||||
/// thin, ogham…) is kept as it is.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n'];
|
||||
/// The only spaces free to change between words; any other (carriage return,
|
||||
/// tab, hair, thin, ogham…) is kept as it is, and so are line breaks and the
|
||||
/// indentation that opens a line: in Markdown they make lists and paragraphs.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];
|
||||
|
||||
/// Invisible format characters (zero-width, bidirectional overrides and
|
||||
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
|
||||
/// Source"). A text of reference never needs them; they are refused.
|
||||
/// Invisible characters: the Unicode default-ignorable set (zero-width,
|
||||
/// bidirectional overrides and isolates, soft hyphen, variation selectors,
|
||||
/// tags, byte order mark…) and control characters other than line breaks and
|
||||
/// tabs (an escape sequence rewrites a terminal). They can make "12" display as
|
||||
/// "21" ("Trojan Source") or hide text in the published files. A text of
|
||||
/// reference never needs them; they are refused.
|
||||
pub fn has_hidden_controls(s: &str) -> bool {
|
||||
s.chars().any(
|
||||
|c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF),
|
||||
)
|
||||
s.chars().any(|c| {
|
||||
(c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|
||||
|| matches!(
|
||||
c as u32,
|
||||
0x00AD
|
||||
| 0x034F
|
||||
| 0x061C
|
||||
| 0x115F..=0x1160
|
||||
| 0x17B4..=0x17B5
|
||||
| 0x180B..=0x180F
|
||||
| 0x200B..=0x200F
|
||||
| 0x202A..=0x202E
|
||||
| 0x2060..=0x206F
|
||||
| 0x3164
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFEFF
|
||||
| 0xFFA0
|
||||
| 0xFFF0..=0xFFF8
|
||||
| 0x1BCA0..=0x1BCA3
|
||||
| 0x1D173..=0x1D17A
|
||||
| 0xE0000..=0xE0FFF
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Apostrophes and quotation marks of every style are one and the same sign:
|
||||
@@ -123,7 +147,16 @@ fn shape(s: &str) -> String {
|
||||
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||
let mut out = String::new();
|
||||
let mut in_word = false;
|
||||
let mut line_start = true;
|
||||
for (i, &c) in cs.iter().enumerate() {
|
||||
if c == '\n' || (line_start && c.is_whitespace()) {
|
||||
// Line breaks and indentation are structure: « - a\n- b » is a list.
|
||||
out.push(c);
|
||||
in_word = false;
|
||||
line_start = true;
|
||||
continue;
|
||||
}
|
||||
line_start = false;
|
||||
if is_latin_letter(c) {
|
||||
if !in_word {
|
||||
out.push('a');
|
||||
@@ -203,16 +236,17 @@ fn words(s: &str) -> Vec<String> {
|
||||
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
||||
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
||||
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
||||
let joined = |x: &String, y: &String| !NEGATIONS.contains(&x.as_str()) && !NEGATIONS.contains(&y.as_str());
|
||||
// Neither the word glued or split nor its pieces may be a negation (« ja mais »).
|
||||
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t));
|
||||
let (mut i, mut j, mut moves) = (0, 0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
if a[i] == b[j] {
|
||||
(i, j) = (i + 1, j + 1);
|
||||
continue;
|
||||
}
|
||||
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&b[j], &b[j + 1]) {
|
||||
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&a[i], &b[j], &b[j + 1]) {
|
||||
(i, j) = (i + 1, j + 2);
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&a[i], &a[i + 1]) {
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&b[j], &a[i], &a[i + 1]) {
|
||||
(i, j) = (i + 2, j + 1);
|
||||
} else {
|
||||
return false;
|
||||
@@ -389,6 +423,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negation_is_never_cut_nor_formed() {
|
||||
// Eighth independent review, 2026-10-21: the pieces were checked, not the word.
|
||||
for (old, new) in [
|
||||
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
|
||||
("aucun compte", "au cun compte"),
|
||||
("sans retour", "san s retour"),
|
||||
("rien de plus", "ri en de plus"),
|
||||
("nowhere else", "now here else"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_and_indentation_are_structure() {
|
||||
for (old, new) in [
|
||||
("- une garantie\n- une autre", "- une garantie - une autre"),
|
||||
("- une garantie\n - une précision", "- une garantie\n- une précision"),
|
||||
("la\ntransmission", "la transmission"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old:?} → {new:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
classify("- une garantie\n- latransmission", "- une garantie\n- la transmission"),
|
||||
EditKind::Typographic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_spaces_and_look_alike_letters_are_kept() {
|
||||
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
|
||||
@@ -518,6 +582,11 @@ mod tests {
|
||||
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
|
||||
assert!(has_hidden_controls("5\u{200b}0"));
|
||||
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
|
||||
assert!(!has_hidden_controls("une ligne\nune autre\tavec tabulation"));
|
||||
// The whole default-ignorable set and the control characters (eighth review).
|
||||
for hidden in ["\u{ad}", "\u{180e}", "\u{fe0f}", "\u{e0041}", "\u{206e}", "\u{1b}[2J", "\u{7f}", "\u{85}"] {
|
||||
assert!(has_hidden_controls(&format!("cer{hidden}tification")), "{hidden:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Team links (2026-10-21): one link shared with a team until a date; each opening
|
||||
-- makes its own invited identity, never an administrator. Additive only.
|
||||
|
||||
CREATE TABLE team_links (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL, -- what the link is for, shown to whoever opens it
|
||||
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on each opening
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
max_uses INTEGER NOT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- The team link an identity came from, if any (shown to administrators).
|
||||
ALTER TABLE identities ADD COLUMN team_link TEXT;
|
||||
@@ -34,6 +34,10 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
|
||||
.await?;
|
||||
let last_seen: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
|
||||
let came_by: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let invitations: Vec<(String, String, bool, String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations
|
||||
WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||
@@ -55,7 +59,9 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
|
||||
"identities": identities.iter().map(|i| json!({
|
||||
"id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin,
|
||||
"lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()),
|
||||
"teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()),
|
||||
})).collect::<Vec<_>>(),
|
||||
"teamLinks": crate::team::list(&state).await?,
|
||||
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
|
||||
"id": hash, "label": label, "admin": admin,
|
||||
"groups": serde_json::from_str::<Vec<String>>(groups).unwrap_or_default(),
|
||||
@@ -126,7 +132,7 @@ pub async fn invite(
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty() || label.chars().count() > 60 {
|
||||
if label.is_empty() || label.chars().count() > 60 || ld_engine::edit::has_hidden_controls(label) {
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
// A new link for an existing person is for invitation identities only: a link
|
||||
|
||||
@@ -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/libredecision-textes.git`.
|
||||
pub remote: String,
|
||||
pub branch: String,
|
||||
/// Private SSH key allowed to push there (deploy key).
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod error;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod team;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
pub mod wot;
|
||||
@@ -96,6 +97,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/auth/g1/start", get(auth::g1_start))
|
||||
.route("/auth/g1/callback", get(auth::g1_callback))
|
||||
.route("/auth/invitation", post(auth::accept_invitation))
|
||||
.route("/auth/team", post(team::join))
|
||||
.route("/auth/team/peek", post(team::peek))
|
||||
.route("/auth/dev", post(auth::dev_login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/me", get(auth::me))
|
||||
@@ -120,6 +123,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/access/groups/{slug}/members/{identity}", delete(access::revoke))
|
||||
.route("/access/invitations", post(access::invite))
|
||||
.route("/access/invitations/{hash}", delete(access::cancel_invitation))
|
||||
.route("/access/team-links", post(team::create))
|
||||
.route("/access/team-links/{hash}", delete(team::revoke))
|
||||
.route("/access/identities/{id}/admin", post(access::set_admin))
|
||||
.route("/access/feedback", get(access::feedback_list))
|
||||
.route("/access/feedback/{id}", post(access::feedback_status))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Team links — one link an administrator shares with a team, until a date and
|
||||
//! for a number of openings. Each opening asks the person's name and makes
|
||||
//! **their own** invited identity, never an administrator: one person, one
|
||||
//! voice, and access management stays with trustinWallet's administrators.
|
||||
//! Someone already signed in keeps their identity and receives the groups.
|
||||
//!
|
||||
//! Limit, assumed: a person who opens the link twice under two names has two
|
||||
//! identities. Where invited identities vote (the test group), administrators
|
||||
//! see in trustinWallet which link each identity came from.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{self, IdentityRow, User, Viewer};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use ld_engine::edit;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
const MAX_DAYS: i64 = 90;
|
||||
const MAX_USES: i64 = 500;
|
||||
const MAX_NAME: usize = 60;
|
||||
|
||||
pub fn link(state: &AppState, token: &str) -> String {
|
||||
format!("{}/equipe#{token}", state.cfg.public_url)
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TeamLink {
|
||||
token_hash: String,
|
||||
label: String,
|
||||
groups: String,
|
||||
created_by: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
/// A link that still opens: not revoked, not expired, openings left.
|
||||
async fn open_link(state: &AppState, token: &str) -> AppResult<TeamLink> {
|
||||
sqlx::query_as::<_, TeamLink>(
|
||||
"SELECT token_hash, label, groups, created_by, expires_at FROM team_links
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
|
||||
)
|
||||
.bind(util::sha256_hex(token.trim()))
|
||||
.bind(util::now())
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::Invalid("invitation_invalid"))
|
||||
}
|
||||
|
||||
fn slugs(raw: &str) -> Vec<String> {
|
||||
serde_json::from_str(raw).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PeekBody {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// What the link opens, before the person gives a name.
|
||||
pub async fn peek(
|
||||
State(state): State<AppState>,
|
||||
Viewer(viewer): Viewer,
|
||||
Json(b): Json<PeekBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let l = open_link(&state, &b.token).await?;
|
||||
let mut names = Vec::new();
|
||||
for slug in slugs(&l.groups) {
|
||||
if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?")
|
||||
.bind(&slug)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"label": l.label,
|
||||
"groups": names,
|
||||
"expiresAt": l.expires_at,
|
||||
"signedInAs": viewer.map(|v| v.display_name),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinBody {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
fn check_name(raw: &str) -> AppResult<&str> {
|
||||
let name = raw.trim();
|
||||
let n = name.chars().count();
|
||||
if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) {
|
||||
return Err(AppError::Invalid("name"));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub async fn join(
|
||||
State(state): State<AppState>,
|
||||
Viewer(viewer): Viewer,
|
||||
Json(b): Json<JoinBody>,
|
||||
) -> AppResult<Response> {
|
||||
let l = open_link(&state, &b.token).await?;
|
||||
// Checked before an opening is counted.
|
||||
let name = match &viewer {
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let name = check_name(b.name.as_deref().unwrap_or_default())?;
|
||||
// Nobody joins under the name of someone already there.
|
||||
let taken: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE display_name = ? COLLATE NOCASE")
|
||||
.bind(name)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if taken > 0 {
|
||||
return Err(AppError::Conflict("name_taken"));
|
||||
}
|
||||
Some(name.to_owned())
|
||||
}
|
||||
};
|
||||
// Count the opening first: of two at the last place, one only goes on.
|
||||
let counted = sqlx::query(
|
||||
"UPDATE team_links SET uses = uses + 1
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
|
||||
)
|
||||
.bind(&l.token_hash)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if counted.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
let (identity_id, session) = match (viewer, name) {
|
||||
(Some(me), _) => (me.id, None),
|
||||
(None, Some(name)) => {
|
||||
let id = format!("inv:{}", util::id());
|
||||
sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link)
|
||||
VALUES (?, 'invite', ?, 0, 0, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&name)
|
||||
.bind(util::now())
|
||||
.bind(&l.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let session = auth::open_session(&state, &id).await?;
|
||||
(id, Some(session))
|
||||
}
|
||||
(None, None) => return Err(AppError::Invalid("name")),
|
||||
};
|
||||
auth::grant_groups(&state, &identity_id, &slugs(&l.groups), Some(&l.created_by)).await?;
|
||||
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
|
||||
let me = auth::load_identity(&state, &identity_id).await?;
|
||||
let mut res = Json(json!({ "me": me })).into_response();
|
||||
if let Some(cookie) = session {
|
||||
res.headers_mut().append(SET_COOKIE, cookie);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn admin_only(user: &IdentityRow) -> AppResult<()> {
|
||||
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBody {
|
||||
label: String,
|
||||
groups: Vec<String>,
|
||||
days: i64,
|
||||
max_uses: i64,
|
||||
}
|
||||
|
||||
/// The link is returned once and never stored in clear.
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Json(b): Json<CreateBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) {
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
|
||||
return Err(AppError::Invalid("team_limits"));
|
||||
}
|
||||
let known: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||
if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) {
|
||||
return Err(AppError::Invalid("groups"));
|
||||
}
|
||||
let token = util::token();
|
||||
sqlx::query(
|
||||
"INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(util::sha256_hex(&token))
|
||||
.bind(label)
|
||||
.bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into()))
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.bind(util::in_days(b.days))
|
||||
.bind(b.max_uses)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created");
|
||||
Ok(Json(json!({ "link": link(&state, &token) })))
|
||||
}
|
||||
|
||||
pub async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(hash): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(by = %user.id, "team link revoked");
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// The links still open, for trustinWallet.
|
||||
pub async fn list(state: &AppState) -> AppResult<Vec<serde_json::Value>> {
|
||||
let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links
|
||||
WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(util::now())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, label, groups, expires, max_uses, uses)| {
|
||||
json!({
|
||||
"id": id, "label": label, "groups": slugs(&groups),
|
||||
"expiresAt": expires, "maxUses": max_uses, "uses": uses,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -251,6 +251,11 @@ async fn editing_is_not_voting() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||||
// A title is one line.
|
||||
let (s, v) = b
|
||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body })))
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title")));
|
||||
// The title never absorbs the start of the body.
|
||||
let title = e2["title"].as_str().expect("title");
|
||||
let (head, rest) = body.split_once(' ').expect("two words");
|
||||
@@ -460,6 +465,89 @@ async fn access_management_is_for_administrators_only() {
|
||||
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_team_link_makes_one_identity_per_opening() {
|
||||
let (mut admin, _) = Client::new().await;
|
||||
admin.login("Yvv", true).await;
|
||||
let mut user = admin.with(None);
|
||||
user.login("Chiara", false).await;
|
||||
let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max });
|
||||
let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN, "administrators only");
|
||||
for (body, code) in [
|
||||
(team(json!([]), 30, 2), "groups"),
|
||||
(team(json!(["nope"]), 30, 2), "groups"),
|
||||
(team(json!(["essai"]), 0, 2), "team_limits"),
|
||||
(team(json!(["essai"]), 30, 501), "team_limits"),
|
||||
] {
|
||||
let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)));
|
||||
}
|
||||
let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let link = v["link"].as_str().expect("link").to_owned();
|
||||
assert!(link.contains("/equipe#"), "{link}");
|
||||
let token = link.split('#').nth(1).expect("token").to_owned();
|
||||
|
||||
let mut first = admin.with(None);
|
||||
let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["label"], "Équipe dev");
|
||||
assert_eq!(v["groups"].as_array().expect("g").len(), 1);
|
||||
assert_eq!(v["signedInAs"], Value::Null);
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false)));
|
||||
assert_eq!(v["me"]["displayName"], "Luna");
|
||||
let luna = v["me"]["id"].as_str().expect("id").to_owned();
|
||||
let (_, g) = first.call("GET", "/api/groups", None).await;
|
||||
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
|
||||
|
||||
// Nobody joins under a name already there; the refusal does not count as an opening.
|
||||
let mut second = admin.with(None);
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
|
||||
// Two openings allowed: the third is refused.
|
||||
let mut third = admin.with(None);
|
||||
let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid")));
|
||||
|
||||
// Someone signed in keeps their identity and receives the groups.
|
||||
let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await;
|
||||
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(v["signedInAs"], "Luna");
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||||
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Administrators see the links still open and where each identity came from; a link revokes.
|
||||
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||||
let links = ov["teamLinks"].as_array().expect("links");
|
||||
assert_eq!(links.len(), 2, "{ov}");
|
||||
assert!(
|
||||
ov["identities"]
|
||||
.as_array()
|
||||
.expect("i")
|
||||
.iter()
|
||||
.any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev")
|
||||
);
|
||||
let open = links.iter().find(|l| l["uses"] == 1).expect("second link");
|
||||
let (s, _) =
|
||||
admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let mut late = admin.with(None);
|
||||
let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
|
||||
let (mut admin, _) = Client::new().await;
|
||||
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
LD_PUBLIC_URL: https://${APP_DOMAIN:-decision.librodrome.org}
|
||||
LD_ADMIN_G1_IDS: ${LD_ADMIN_G1_IDS:-idty:8352}
|
||||
LD_BOOTSTRAP_ADMIN: "1"
|
||||
LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-git@git.duniter.org:Yvv/libredecision-textes.git}
|
||||
LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-gitea@git.open.us.org:yvv/libredecision-textes.git}
|
||||
LD_PUBLISH_BRANCH: main
|
||||
RUST_LOG: info,sqlx=warn,tower_http=warn
|
||||
volumes:
|
||||
|
||||
@@ -12,14 +12,16 @@ ou de fond), jamais contre la correction précédente :
|
||||
comptent pour un même signe) ;
|
||||
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
|
||||
restent identiques à la lettre ;
|
||||
- les espaces (espace, insécable, fine insécable, saut de ligne) ne font que coller ou couper
|
||||
des mots, deux au plus, jamais sur une négation, jamais en déplaçant une frontière ;
|
||||
- les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux
|
||||
au plus, jamais en touchant une négation (ses morceaux ou le mot entier), jamais en déplaçant
|
||||
une frontière ; sauts de ligne et indentation font partie de la forme ;
|
||||
- le titre et le corps sont jugés chacun pour soi, puis ensemble ;
|
||||
- au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre
|
||||
ajoutée, retirée ou deux voisines inversées ; jamais aux deux premières ni aux deux dernières
|
||||
lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de
|
||||
position) ;
|
||||
- les caractères invisibles de mise en forme sont refusés partout où l'on écrit.
|
||||
- les caractères invisibles (ensemble Unicode des ignorables, contrôles autres que saut de
|
||||
ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne.
|
||||
|
||||
Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes
|
||||
antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ.
|
||||
@@ -35,6 +37,10 @@ devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La
|
||||
(« Si non » → « Sinon »), titre jugé avec le corps (le titre en absorbait le début), caractères
|
||||
invisibles non contrôlés au découpage d'un brouillon ; et deux remarques : tabulation et retour
|
||||
chariot ne sont plus des espaces libres, les lettres latines rares font partie de la forme.
|
||||
La huitième (`c3d6bf8 : ok`) a laissé deux points, corrigés ensuite : une négation coupée en
|
||||
morceaux qui n'en sont pas (« ja mais »), des invisibles hors de la liste (trait d'union
|
||||
conditionnel, étiquettes) ; et deux remarques : un saut de ligne dans un titre, une liste
|
||||
Markdown fusionnée par les espaces.
|
||||
|
||||
**Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une
|
||||
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
|
||||
|
||||
@@ -10,8 +10,10 @@ est remis une fois (rien ne se perd sur un redémarrage, un document inchangé n
|
||||
commit : sa date est celle du dernier changement, jamais « maintenant »). Un travailleur rend `fr.md`, `en.md`,
|
||||
`es.md` et `document.json` dans le clone du serveur, commit, pousse avec une clé de déploiement
|
||||
générée par le serveur (sa partie publique s'affiche dans trustinWallet), et réessaie avec un
|
||||
délai croissant. Dépôt : `git.duniter.org/Yvv/libredecision-textes`, un dossier par groupe et
|
||||
par document. Le français fait foi ; les traductions sont marquées éditoriales.
|
||||
délai croissant. Dépôt : `git.open.us.org/yvv/libredecision-textes`, un dossier par groupe et
|
||||
par document — provisoire (Yvv, 2026-10-21) : viendront un dépôt pour l'ouverture, par
|
||||
transfert, puis le dépôt officiel pour les textes en vigueur ; changer de dépôt tient en une
|
||||
ligne (`LD_PUBLISH_REMOTE`), le serveur réaligne son clone. Le français fait foi ; les traductions sont marquées éditoriales.
|
||||
|
||||
**Coût.** La publication est asynchrone (quelques secondes, plus en cas de panne) ; l'état
|
||||
de la file se lit dans trustinWallet.
|
||||
|
||||
@@ -8,7 +8,16 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
|
||||
- Connexion Ğ1 : libreDecision est client OAuth2 de connect.monnaie-libre.fr (comme le forum).
|
||||
Le compte est la clé d'identité (`idty:N`) ; on garde le nom affiché et le statut de membre.
|
||||
- En attendant l'inscription du client : liens d'invitation à usage unique (14 jours), le
|
||||
secret dans le fragment de l'URL (jamais dans les journaux du serveur).
|
||||
secret dans le fragment de l'URL (jamais dans les journaux du serveur). Un administrateur
|
||||
peut redonner un lien à une personne invitée (autre appareil, session perdue).
|
||||
- **Liens d'équipe** (Yvv, 2026-10-21 : « je vais l'utiliser plein de fois, pendant tout le
|
||||
dev, et la partager avec une équipe ») : un lien réutilisable jusqu'à une date (90 jours au
|
||||
plus) et pour un nombre de personnes (500 au plus), révocable. Chaque ouverture demande un
|
||||
nom et crée **sa propre** identité d'invitation, jamais administratrice ; un nom déjà pris
|
||||
est refusé ; une personne déjà connectée garde son identité et reçoit les groupes.
|
||||
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
|
||||
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
|
||||
libre (seule la toile vote), à surveiller dans le groupe d'essai.
|
||||
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https, 30 jours, jeton haché en base ;
|
||||
en-tête anti-CSRF sur toute écriture.
|
||||
- Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du
|
||||
|
||||
@@ -19,7 +19,9 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
||||
connexion. Le menu complet à gauche : Décisions · Docs de référence · Mandats · Protocoles
|
||||
(seul Docs de référence est actif).
|
||||
2. **Connexion Ğ1** — la même que le forum Monnaie libre (connect.monnaie-libre.fr). En attendant
|
||||
l'inscription du client OAuth2, des **liens d'invitation** à usage unique.
|
||||
l'inscription du client OAuth2, des **liens d'invitation** à usage unique (une personne),
|
||||
et des **liens d'équipe** réutilisables jusqu'à une date (chaque ouverture crée sa propre
|
||||
identité, jamais administratrice).
|
||||
3. **Groupes** : avec une clé Ğ1, on voit Monnaie libre, Axiom, Librodrome et le Groupe local
|
||||
Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à
|
||||
ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres
|
||||
@@ -98,14 +100,16 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u
|
||||
« 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; « lettre
|
||||
latine » s'entend de celles du français, de l'anglais et de l'espagnol (les lettres latines
|
||||
rares qui imitent un signe, « ǃ » ou « Ʒ », font partie de la forme) ; « espace » s'entend de
|
||||
l'espace, de l'insécable, de la fine insécable et du saut de ligne (ni tabulation ni retour
|
||||
chariot) ;
|
||||
l'espace, de l'insécable et de la fine insécable entre deux mots ; le **saut de ligne** et
|
||||
l'**indentation** qui ouvre une ligne font partie de la forme (en Markdown, ils font les
|
||||
listes et les paragraphes) ; un titre tient sur une ligne ;
|
||||
- les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un
|
||||
chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
|
||||
entre accents graves aussi ;
|
||||
- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais sur un mot de
|
||||
négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond), et jamais
|
||||
déplacer une frontière (« les avoir » → « le savoir » est du fond) ;
|
||||
- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une
|
||||
négation — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne certifier » →
|
||||
« necertifier », « jamais » → « ja mais » sont du fond) —, et jamais déplacer une frontière
|
||||
(« les avoir » → « le savoir » est du fond) ;
|
||||
- le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du
|
||||
corps), puis ensemble (deux mots au plus en tout) ;
|
||||
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux
|
||||
@@ -114,7 +118,10 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u
|
||||
un mot de négation, de quantité ou de position (ne, jamais, deux, majorité, tous, dessus…) ;
|
||||
- chaque correction est mesurée contre le **texte voté**, jamais contre la correction
|
||||
précédente : les glissements ne s'additionnent pas ;
|
||||
- les caractères invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout ;
|
||||
- les caractères invisibles (tout l'ensemble Unicode des caractères ignorables : inversion du
|
||||
sens d'écriture, largeur nulle, trait d'union conditionnel, sélecteurs de variante,
|
||||
étiquettes… ; et les caractères de contrôle autres que saut de ligne et tabulation) sont
|
||||
refusés partout où l'on écrit ;
|
||||
- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur
|
||||
ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »).
|
||||
|
||||
@@ -144,7 +151,7 @@ ouverte au vote. Son auteur peut la retirer.
|
||||
Chaque entrée en vigueur (et la publication initiale) passe par une file d'envoi : le serveur
|
||||
écrit `fr.md`, `en.md`, `es.md` (front matter : document, langue, source, mise à jour ; les
|
||||
traductions marquées éditoriales) et `document.json`, commit et pousse sur
|
||||
`git.duniter.org/Yvv/libredecision-textes`, chemin `monnaie-libre/acte-engagement-certification/`.
|
||||
`git.open.us.org/yvv/libredecision-textes`, chemin `monnaie-libre/acte-engagement-certification/`.
|
||||
Échec → nouvel essai avec délai croissant. La clé de publication est générée par le serveur ;
|
||||
sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture)
|
||||
du dépôt. Le groupe d'essai ne publie jamais.
|
||||
@@ -173,7 +180,7 @@ sans toucher au texte de la communauté. Chiara et Yvv en sont membres.
|
||||
| Stack | Rust (serveur, moteur) + Svelte (interface), pour la durée, la performance, la modularité |
|
||||
| Domaine | le produit à la racine `decision.librodrome.org` ; la maquette sur `maquette.decision.librodrome.org` |
|
||||
| Connexion | client OAuth2 de connect.monnaie-libre.fr (demande à poka) ; liens d'invitation en attendant |
|
||||
| Dépôt des textes | `git.duniter.org/Yvv/libredecision-textes` (compte d'Yvv) |
|
||||
| Dépôt des textes | `git.open.us.org/yvv/libredecision-textes` pour l'instant (2026-10-21) ; puis un dépôt pour l'ouverture, par transfert, et le dépôt officiel pour les textes en vigueur |
|
||||
| Garde-fou du vote permanent | nombre minimal de votants « pour » (C) |
|
||||
| Langues | fr, en, es ; le français est voté, les traductions sont éditoriales |
|
||||
| Chiara | testeuse et codeuse |
|
||||
|
||||
@@ -20,6 +20,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
|
||||
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
||||
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
||||
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
|
||||
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé ; « Révoquer » ferme le lien |
|
||||
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
||||
|
||||
## Au-delà de la liste
|
||||
@@ -43,3 +44,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|
||||
| 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts |
|
||||
| 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts |
|
||||
| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |
|
||||
| 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts |
|
||||
|
||||
@@ -17,7 +17,9 @@ les contradictions, l'inertie votée, la publication. Puis `CONTROLES.md` : ce q
|
||||
| Un compte sur **git.open.us.org**, collaboratrice de `yvv/decision` | pousser tes branches, ouvrir des demandes de fusion | Yvv |
|
||||
|
||||
Le lien d'invitation s'ouvre **une seule fois** et expire en 14 jours ; il ouvre ta session sur
|
||||
ce navigateur pour 30 jours.
|
||||
ce navigateur pour 30 jours. Sur un autre appareil, demande à Yvv un nouveau lien de
|
||||
connexion. Si Yvv t'envoie plutôt un **lien d'équipe** (`/equipe#…`), il te demande ton nom et
|
||||
te crée ta propre identité ; ne l'ouvre qu'une fois, puis reviens par ta session.
|
||||
|
||||
## 3. Tester sans rien installer
|
||||
|
||||
@@ -27,7 +29,7 @@ ce navigateur pour 30 jours.
|
||||
une proposition passe avec vos deux voix « pour » — la formule demande la majorité de
|
||||
l'électorat quand il est si petit. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis,
|
||||
tu proposes, tu signales ; le vote y est réservé aux membres de la toile (avec ta clé Ğ1).
|
||||
3. Passe les contrôles C1–C11 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne.
|
||||
3. Passe les contrôles C1–C12 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne.
|
||||
4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce
|
||||
que tu attendais, ce qui s'est passé. La page, la langue et la taille de l'écran sont jointes.
|
||||
|
||||
|
||||
@@ -155,3 +155,27 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click();
|
||||
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => {
|
||||
const yvv = await person(browser, 'Yvv', true);
|
||||
await yvv.goto('/acces');
|
||||
const team = yvv.locator('section', { has: yvv.getByRole('heading', { name: "Liens d'équipe" }) });
|
||||
await team.getByLabel('Pour quelle équipe').fill('Équipe de test');
|
||||
await team.getByLabel("Groupe d'essai").check();
|
||||
await team.getByRole('button', { name: "Créer le lien d'équipe" }).click();
|
||||
const link = (await team.locator('.link code').textContent()) ?? '';
|
||||
expect(link).toContain('/equipe#');
|
||||
for (const name of ['Luna', 'Paul']) {
|
||||
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`/equipe#${link.split('#')[1]}`);
|
||||
await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible();
|
||||
await page.getByLabel('Ton nom').fill(name);
|
||||
await page.getByRole('button', { name: 'Rejoindre' }).click();
|
||||
await expect(page).toHaveURL(/\/groupes$/);
|
||||
await ctx.close();
|
||||
}
|
||||
await yvv.reload();
|
||||
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
|
||||
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -55,6 +55,17 @@ export const en: Messages = {
|
||||
'invite.working': 'Signing in…',
|
||||
'invite.invalid': 'This invitation link is no longer valid: it has already been used or it has expired. Ask the person who sent it to you for a new link.',
|
||||
'invite.missing': 'This link is incomplete. Open the whole invitation link, exactly as you received it.',
|
||||
'team.title': 'Join a team',
|
||||
'team.working': 'Reading the link…',
|
||||
'team.invalid': 'This team link is no longer valid: it has expired, it has been revoked or all its places are taken. Ask the person who sent it to you for a new link.',
|
||||
'team.missing': 'This link is incomplete. Open the whole team link, exactly as you received it.',
|
||||
'team.opens': '“{label}” opens for you: {groups}.',
|
||||
'team.until': 'Link valid until {date}.',
|
||||
'team.name': 'Your name',
|
||||
'team.name.hint': 'The name the group will see you under. One person, one identity: if you have already joined, come back in the browser where your session is open.',
|
||||
'team.join': 'Join',
|
||||
'team.signed': 'Your session is open under the name {name}: these groups are added to your identity.',
|
||||
'team.add': 'Add these groups',
|
||||
|
||||
'groups.title': 'My groups',
|
||||
'groups.same.key': 'Groups that use the same key',
|
||||
@@ -275,10 +286,22 @@ export const en: Messages = {
|
||||
'access.invite.copied': 'Link copied.',
|
||||
'access.invite.cancel': 'Cancel',
|
||||
'access.invite.pending': 'Pending',
|
||||
'access.team': 'Team links',
|
||||
'access.team.intro': 'A link to share with a team, until a date. Each person who opens it gives their name and receives their own identity, never the administrator role.',
|
||||
'access.team.label': 'For which team',
|
||||
'access.team.days': 'Valid for how many days',
|
||||
'access.team.uses': 'How many people at most',
|
||||
'access.team.create': 'Create the team link',
|
||||
'access.team.link': 'Team link, to share: it works until its date or its last place. It will not be shown again: copy it now.',
|
||||
'access.team.state': '{uses} / {max} people · until {date}',
|
||||
'access.team.revoke': 'Revoke',
|
||||
'access.team.via': 'came through “{label}”',
|
||||
'access.identities': 'Identities',
|
||||
'access.admin': 'administrator',
|
||||
'access.admin.give': 'Make administrator',
|
||||
'access.admin.take': 'Remove role',
|
||||
'access.relink': 'New sign-in link',
|
||||
'access.relink.link': 'Sign-in link for {name}, single use, valid for 14 days — to open their session on another device. It will not be shown again: copy it now.',
|
||||
'access.kind.g1': 'Ğ1',
|
||||
'access.kind.invite': 'invitation',
|
||||
'access.kind.dev': 'dev',
|
||||
@@ -314,6 +337,11 @@ export const en: Messages = {
|
||||
'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): remove them.',
|
||||
'error.changed': 'The text has changed in the meantime: reload it, then try again.',
|
||||
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
|
||||
'error.label': 'The name is empty or too long (60 characters at most).',
|
||||
'error.name': 'Your name has 2 to 60 characters, with no invisible character.',
|
||||
'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.',
|
||||
'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
|
||||
'error.groups': 'Choose at least one group.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
@@ -55,6 +55,17 @@ export const es: Messages = {
|
||||
'invite.working': 'Conectando…',
|
||||
'invite.invalid': 'Este enlace de invitación ya no es válido: ya se ha usado o ha caducado. Pide un enlace nuevo a la persona que te lo envió.',
|
||||
'invite.missing': 'Este enlace está incompleto. Abre el enlace de invitación completo, tal como lo recibiste.',
|
||||
'team.title': 'Unirte a un equipo',
|
||||
'team.working': 'Leyendo el enlace…',
|
||||
'team.invalid': 'Este enlace de equipo ya no es válido: ha caducado, ha sido revocado o todas sus plazas están ocupadas. Pide un enlace nuevo a la persona que te lo envió.',
|
||||
'team.missing': 'Este enlace está incompleto. Abre el enlace de equipo completo, tal como lo recibiste.',
|
||||
'team.opens': '«{label}» te abre: {groups}.',
|
||||
'team.until': 'Enlace válido hasta el {date}.',
|
||||
'team.name': 'Tu nombre',
|
||||
'team.name.hint': 'El nombre con el que el grupo te verá. Una persona, una identidad: si ya te uniste, vuelve en el navegador donde tienes la sesión abierta.',
|
||||
'team.join': 'Unirme',
|
||||
'team.signed': 'Tu sesión está abierta con el nombre {name}: estos grupos se añaden a tu identidad.',
|
||||
'team.add': 'Añadir estos grupos',
|
||||
|
||||
'groups.title': 'Mis grupos',
|
||||
'groups.same.key': 'Grupos que usan la misma clave',
|
||||
@@ -275,10 +286,22 @@ export const es: Messages = {
|
||||
'access.invite.copied': 'Enlace copiado.',
|
||||
'access.invite.cancel': 'Cancelar',
|
||||
'access.invite.pending': 'Pendiente',
|
||||
'access.team': 'Enlaces de equipo',
|
||||
'access.team.intro': 'Un enlace para compartir con un equipo, hasta una fecha. Cada persona que lo abre da su nombre y recibe su propia identidad, nunca el rol de administrador.',
|
||||
'access.team.label': 'Para qué equipo',
|
||||
'access.team.days': 'Válido cuántos días',
|
||||
'access.team.uses': 'Cuántas personas como máximo',
|
||||
'access.team.create': 'Crear el enlace de equipo',
|
||||
'access.team.link': 'Enlace de equipo, para compartir: sirve hasta su fecha o su última plaza. No se volverá a mostrar: cópialo ahora.',
|
||||
'access.team.state': '{uses} / {max} personas · hasta el {date}',
|
||||
'access.team.revoke': 'Revocar',
|
||||
'access.team.via': 'llegó por «{label}»',
|
||||
'access.identities': 'Identidades',
|
||||
'access.admin': 'administrador',
|
||||
'access.admin.give': 'Nombrar administrador',
|
||||
'access.admin.take': 'Retirar el rol',
|
||||
'access.relink': 'Nuevo enlace de conexión',
|
||||
'access.relink.link': 'Enlace de conexión para {name}, de un solo uso, válido 14 días — para abrir su sesión en otro dispositivo. No se volverá a mostrar: cópialo ahora.',
|
||||
'access.kind.g1': 'Ğ1',
|
||||
'access.kind.invite': 'invitación',
|
||||
'access.kind.dev': 'dev',
|
||||
@@ -314,6 +337,11 @@ export const es: Messages = {
|
||||
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): quítalos.',
|
||||
'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
|
||||
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
|
||||
'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).',
|
||||
'error.name': 'Tu nombre tiene de 2 a 60 caracteres, sin caracteres invisibles.',
|
||||
'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.',
|
||||
'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.',
|
||||
'error.groups': 'Elige al menos un grupo.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
'common.cancel': 'Cancelar',
|
||||
|
||||
@@ -58,6 +58,17 @@ export const fr = {
|
||||
'invite.working': 'Connexion en cours…',
|
||||
'invite.invalid': "Ce lien d'invitation n'est plus valable : il a déjà servi ou il a expiré. Demande un nouveau lien à la personne qui te l'a envoyé.",
|
||||
'invite.missing': "Ce lien est incomplet. Ouvre le lien d'invitation entier, tel que tu l'as reçu.",
|
||||
'team.title': 'Rejoindre une équipe',
|
||||
'team.working': 'Lecture du lien…',
|
||||
'team.invalid': "Ce lien d'équipe n'est plus valable : il a expiré, il a été révoqué ou toutes ses places sont prises. Demande un nouveau lien à la personne qui te l'a envoyé.",
|
||||
'team.missing': "Ce lien est incomplet. Ouvre le lien d'équipe entier, tel que tu l'as reçu.",
|
||||
'team.opens': '« {label} » t’ouvre : {groups}.',
|
||||
'team.until': "Lien valable jusqu'au {date}.",
|
||||
'team.name': 'Ton nom',
|
||||
'team.name.hint': 'Le nom sous lequel le groupe te verra. Une personne, une identité : si tu as déjà rejoint, reviens dans le navigateur où ta session est ouverte.',
|
||||
'team.join': 'Rejoindre',
|
||||
'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.',
|
||||
'team.add': 'Ajouter ces groupes',
|
||||
|
||||
'groups.title': 'Mes groupes',
|
||||
'groups.same.key': 'Groupes qui utilisent la même clé',
|
||||
@@ -278,10 +289,22 @@ export const fr = {
|
||||
'access.invite.copied': 'Lien copié.',
|
||||
'access.invite.cancel': 'Annuler',
|
||||
'access.invite.pending': 'En attente',
|
||||
'access.team': "Liens d'équipe",
|
||||
'access.team.intro': "Un lien à partager avec une équipe, jusqu'à une date. Chaque personne qui l'ouvre donne son nom et reçoit sa propre identité, jamais le rôle d'administrateur.",
|
||||
'access.team.label': 'Pour quelle équipe',
|
||||
'access.team.days': 'Valable combien de jours',
|
||||
'access.team.uses': 'Combien de personnes au plus',
|
||||
'access.team.create': "Créer le lien d'équipe",
|
||||
'access.team.link': "Lien d'équipe, à partager : il sert jusqu'à sa date ou à sa dernière place. Il ne s'affichera plus : copie-le maintenant.",
|
||||
'access.team.state': '{uses} / {max} personnes · jusqu’au {date}',
|
||||
'access.team.revoke': 'Révoquer',
|
||||
'access.team.via': 'venue par « {label} »',
|
||||
'access.identities': 'Identités',
|
||||
'access.admin': 'administrateur',
|
||||
'access.admin.give': 'Rendre administrateur',
|
||||
'access.admin.take': 'Retirer le rôle',
|
||||
'access.relink': 'Nouveau lien de connexion',
|
||||
'access.relink.link': "Lien de connexion pour {name}, à usage unique, valable 14 jours — pour ouvrir sa session sur un autre appareil. Il ne s'affichera plus : copie-le maintenant.",
|
||||
'access.kind.g1': 'Ğ1',
|
||||
'access.kind.invite': 'invitation',
|
||||
'access.kind.dev': 'dev',
|
||||
@@ -317,6 +340,11 @@ export const fr = {
|
||||
'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : retire-les.',
|
||||
'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
|
||||
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
|
||||
'error.label': 'Le nom est vide ou trop long (60 signes au plus).',
|
||||
'error.name': 'Ton nom fait de 2 à 60 signes, sans caractère invisible.',
|
||||
'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.',
|
||||
'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
|
||||
'error.groups': 'Choisis au moins un groupe.',
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
'common.cancel': 'Annuler',
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import LinkReveal from '$lib/components/LinkReveal.svelte';
|
||||
import TeamLinks, { type TeamLink } from '$lib/components/TeamLinks.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
|
||||
interface Overview {
|
||||
publication: { remote: string | null; publicKey: string | null };
|
||||
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
|
||||
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[];
|
||||
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null }[];
|
||||
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
|
||||
teamLinks: TeamLink[];
|
||||
}
|
||||
interface Feedback {
|
||||
feedback: { id: number; author: string | null; route: string; body: string; createdAt: string; status: 'new' | 'seen' | 'done' }[];
|
||||
@@ -29,6 +31,7 @@
|
||||
let groups = $state<string[]>([]);
|
||||
let admin = $state(false);
|
||||
let link = $state('');
|
||||
let relinked = $state<{ id: string; name: string; link: string } | null>(null);
|
||||
let error = $state('');
|
||||
|
||||
const nameOf = (id: string) => data.value?.identities.find((i) => i.id === id)?.displayName ?? id;
|
||||
@@ -54,9 +57,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(link);
|
||||
toasts.show(t('access.invite.copied'));
|
||||
// A new sign-in link for an invited person (another device, a lost session).
|
||||
async function relink(id: string, name: string) {
|
||||
await act(async () => {
|
||||
const r = await api.post<{ link: string }>('/access/invitations', { label: name, identityId: id });
|
||||
relinked = { id, name, link: r.link };
|
||||
});
|
||||
}
|
||||
|
||||
async function status(id: number, s: 'seen' | 'done') {
|
||||
@@ -118,13 +124,7 @@
|
||||
<label class="check"><input type="checkbox" bind:checked={admin} /> {t('access.invite.admin')}</label>
|
||||
</fieldset>
|
||||
<button class="btn primary small" type="submit">{t('access.invite.create')}</button>
|
||||
{#if link}
|
||||
<div class="link">
|
||||
<p class="small">{t('access.invite.link')}</p>
|
||||
<code>{link}</code>
|
||||
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if link}<LinkReveal {link} note={t('access.invite.link')} />{/if}
|
||||
</form>
|
||||
{#if data.value.invitations.length}
|
||||
<ul class="list">
|
||||
@@ -138,6 +138,8 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<TeamLinks groups={data.value.groups} links={data.value.teamLinks} {act} />
|
||||
|
||||
<section>
|
||||
<h2 class="eyebrow">{t('access.identities')}</h2>
|
||||
<ul class="list">
|
||||
@@ -147,9 +149,14 @@
|
||||
<b>{i.displayName}</b> · {t(`access.kind.${i.kind}`)}
|
||||
{#if i.g1Member} · {t('access.member')}{/if}
|
||||
{#if i.admin} · <span class="tag water">{t('access.admin')}</span>{/if}
|
||||
{#if i.teamLink} · <span class="faint small">{t('access.team.via', { label: i.teamLink })}</span>{/if}
|
||||
</span>
|
||||
<span class="row">
|
||||
{#if i.kind === 'invite'}<button class="btn quiet small" type="button" onclick={() => relink(i.id, i.displayName)}>{t('access.relink')}</button>{/if}
|
||||
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
|
||||
</span>
|
||||
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
|
||||
</li>
|
||||
{#if relinked?.id === i.id}<li class="reveal"><LinkReveal link={relinked.link} note={t('access.relink.link', { name: relinked.name })} /></li>{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -253,19 +260,8 @@
|
||||
.block > .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.link {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.link code {
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.link .btn {
|
||||
justify-self: start;
|
||||
.list li.reveal {
|
||||
display: block;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { Me } from '$lib/types';
|
||||
|
||||
interface Peek {
|
||||
label: string;
|
||||
groups: string[];
|
||||
expiresAt: string;
|
||||
signedInAs: string | null;
|
||||
}
|
||||
|
||||
let phase = $state<'working' | 'invalid' | 'missing' | 'ready'>('working');
|
||||
let link = $state<Peek | null>(null);
|
||||
let token = '';
|
||||
let name = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
// The token travels in the fragment: it never reaches a server log.
|
||||
token = location.hash.slice(1).trim();
|
||||
history.replaceState(null, '', '/equipe');
|
||||
if (!/^[0-9a-f]{64}$/.test(token)) {
|
||||
phase = 'missing';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
link = await api.post<Peek>('/auth/team/peek', { token });
|
||||
phase = 'ready';
|
||||
} catch {
|
||||
phase = 'invalid';
|
||||
}
|
||||
});
|
||||
|
||||
async function join(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
|
||||
session.me = r.me;
|
||||
await goto('/groupes', { replaceState: true });
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : 'internal';
|
||||
if (code === 'invitation_invalid') phase = 'invalid';
|
||||
else error = t(`error.${code}` as Key);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('team.title')}</h1>
|
||||
{#if phase === 'ready' && link}
|
||||
<p class="lead">{t('team.opens', { label: link.label, groups: link.groups.join(', ') })}</p>
|
||||
<p class="faint small">{t('team.until', { date: i18n.date(link.expiresAt) })}</p>
|
||||
<form class="card block" onsubmit={join}>
|
||||
{#if link.signedInAs}
|
||||
<p>{t('team.signed', { name: link.signedInAs })}</p>
|
||||
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span>{t('team.name')}</span>
|
||||
<input class="input" bind:value={name} minlength="2" maxlength="60" autocomplete="name" required />
|
||||
</label>
|
||||
<p class="faint small">{t('team.name.hint')}</p>
|
||||
<button class="btn primary" type="submit" disabled={busy}>{t('team.join')}</button>
|
||||
{/if}
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<p class="muted" role="status">{t(`team.${phase}` as Key)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
.lead {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
.block {
|
||||
padding: 1.1rem 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.block > .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user