v3 : points de la relecture indépendante — une personne une voix, ordre de déploiement, durcissements
- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la toile votent (trustin_access::votes_in) ; une identité d'invitation y lit, propose et signale. L'interface le dit à la place des boutons (fr/en/es). - Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé avant de créer l'identité (deux acceptations simultanées : une seule passe). - Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »). - Redirection après connexion : caractères de contrôle refusés. - En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy. - Déploiement : le produit passe en premier ; la maquette attend son succès. Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de « restart: always » (ils reviendraient au redémarrage de Docker). - Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-10
@@ -8,11 +8,9 @@ when:
|
|||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# La maquette libère d'abord la racine (elle part sur maquette.decision.librodrome.org) ;
|
# Le produit passe en premier : tant que son image n'est pas construite et testée, la
|
||||||
# le produit se déploie ensuite, même si la maquette a échoué.
|
# maquette continue de servir la racine. La maquette (maquette.yml) attend ce workflow
|
||||||
depends_on:
|
# et ne part sur maquette.decision.librodrome.org qu'après un déploiement réussi.
|
||||||
- maquette
|
|
||||||
runs_on: [success, failure]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
@@ -102,15 +100,19 @@ steps:
|
|||||||
- DOMAIN=decision.librodrome.org
|
- DOMAIN=decision.librodrome.org
|
||||||
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
|
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
|
||||||
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
|
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
|
||||||
# Tout conteneur qui revendique encore la racine (maquette pas encore déplacée,
|
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
|
||||||
# ou en échec) est arrêté : la racine est au produit, et fabio partagerait le trafic.
|
# déplacement, d'anciennes stacks ehv-decision-main-*) est arrêté et sort de
|
||||||
|
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
|
||||||
|
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
|
||||||
|
# recréée par son propre workflow, sous son nouveau domaine.
|
||||||
- |
|
- |
|
||||||
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
|
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
|
||||||
for C in $(docker ps -q --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -q --filter "label=SERVICE_8002_TAGS=$TAG"); do
|
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
|
||||||
NAME=$(docker inspect --format '{{.Name}}' "$C")
|
NAME=$(docker inspect --format '{{.Name}}' "$C")
|
||||||
[ "$NAME" = "/lpk-libredecision-app" ] && continue
|
[ "$NAME" = "/lpk-libredecision-app" ] && continue
|
||||||
echo "Arret de $NAME (revendique $TAG)"
|
echo "Retrait de $NAME de la racine (revendique $TAG)"
|
||||||
docker stop "$C"
|
docker update --restart=no "$C" >/dev/null
|
||||||
|
docker stop "$C" >/dev/null
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
# Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1.
|
# Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1.
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# La maquette Nuxt v2 (FastAPI + Postgres + IPFS), conservée telle quelle pour
|
# La maquette Nuxt v2 (FastAPI + Postgres + IPFS), conservée telle quelle pour
|
||||||
# présenter le projet. Depuis la v3 elle vit sur maquette.decision.librodrome.org ;
|
# présenter le projet. Depuis la v3 elle vit sur maquette.decision.librodrome.org ;
|
||||||
# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org et
|
# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org.
|
||||||
# attend la fin de ce workflow, qui libère la racine.
|
# Ce workflow attend le produit : si le produit échoue, la maquette ne bouge pas.
|
||||||
# Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue.
|
# Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue.
|
||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: security-check
|
- name: security-check
|
||||||
@@ -252,6 +255,6 @@ steps:
|
|||||||
- name: notify-failure
|
- name: notify-failure
|
||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
commands:
|
commands:
|
||||||
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
- 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||||
when:
|
when:
|
||||||
- status: failure
|
- status: failure
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ développer.
|
|||||||
- **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt
|
- **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt
|
||||||
LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur
|
LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur
|
||||||
**decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur
|
**decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur
|
||||||
**maquette.decision.librodrome.org** (le produit attend la maquette, qui libère la racine).
|
**maquette.decision.librodrome.org** (le produit passe en premier et retire de la racine tout conteneur qui la revendique ; la maquette attend un déploiement réussi du produit).
|
||||||
Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement.
|
Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
@@ -50,7 +50,7 @@ web/src/
|
|||||||
routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
||||||
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
|
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
|
||||||
# protocoles (à venir)
|
# protocoles (à venir)
|
||||||
web/e2e/ # Playwright C1–C10 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
web/e2e/ # Playwright C1–C11 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
||||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml · maquette.yml
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
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
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
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 : 11 contrôles
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 12 contrôles
|
||||||
|
|
||||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd web && npm run dev # :3002 à chaud
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ cd web
|
|||||||
npm run check # svelte-check, zéro avertissement
|
npm run check # svelte-check, zéro avertissement
|
||||||
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
|
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
|
||||||
npm run build
|
npm run build
|
||||||
npm run e2e # Playwright : C1–C10 (bureau) + M1 (mobile), serveur jetable sur :8092
|
npm run e2e # Playwright : C1–C11 (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.
|
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
|
||||||
|
|||||||
+10
-4
@@ -7,13 +7,19 @@
|
|||||||
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer.
|
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer.
|
||||||
|
|
||||||
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
|
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
|
||||||
- Boucle : Rust (fmt, clippy `-D warnings`, 54 tests dont 13 scénarios d'API et la
|
- Boucle : Rust (fmt, clippy `-D warnings`, 57 tests dont 14 scénarios d'API et la
|
||||||
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
|
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
|
||||||
e2e 11 / 11 (C1–C10, M1). L'image Docker rejoue tout et a été essayée localement :
|
e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement :
|
||||||
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
|
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
|
||||||
des journaux), publication immédiate au démarrage, redémarrage sans commit superflu.
|
des journaux), publication immédiate au démarrage, redémarrage sans commit superflu.
|
||||||
- Déploiement : `.woodpecker/maquette.yml` (la maquette part sur
|
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la
|
||||||
maquette.decision.librodrome.org) puis `.woodpecker/app.yml` (le produit à la racine).
|
racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part
|
||||||
|
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
|
||||||
|
- Relecture indépendante avant mise en ligne (`1f9c241 : ok`) ; ses points « à corriger
|
||||||
|
ensuite » ont été traités avant de livrer : ordre des workflows, anciens conteneurs en
|
||||||
|
`restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une personne,
|
||||||
|
une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant usage,
|
||||||
|
un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement.
|
||||||
|
|
||||||
## Ce qui attend Yvv
|
## Ce qui attend Yvv
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
cd web && npm run check && npm test && npm run build && npm run e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
Les contrôles d'usage (C1–C10, M1) sont décrits dans
|
Les contrôles d'usage (C1–C11, M1) sont décrits dans
|
||||||
[`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ;
|
[`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ;
|
||||||
les tests de Chiara en sont la version humaine.
|
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/
|
web/
|
||||||
src/lib/ # api, session, i18n (fr source, en, es), composants
|
src/lib/ # api, session, i18n (fr source, en, es), composants
|
||||||
src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)…
|
src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)…
|
||||||
e2e/ # Playwright : C1–C10, M1
|
e2e/ # Playwright : C1–C11, M1
|
||||||
docker/ # app.Dockerfile + app-compose.yml (sonic)
|
docker/ # app.Dockerfile + app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml (le produit) · maquette.yml (la maquette)
|
.woodpecker/ # app.yml (le produit) · maquette.yml (la maquette)
|
||||||
docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/
|
docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ fn skeleton(s: &str) -> String {
|
|||||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The numbers of a text, as written: "50" and "5,0", "1 5" and "15" differ.
|
||||||
|
fn numbers(s: &str) -> Vec<&str> {
|
||||||
|
s.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Folded words (runs of letters/digits).
|
/// Folded words (runs of letters/digits).
|
||||||
fn words(s: &str) -> Vec<String> {
|
fn words(s: &str) -> Vec<String> {
|
||||||
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
||||||
@@ -57,6 +62,10 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
|||||||
if old == new {
|
if old == new {
|
||||||
return EditKind::Unchanged;
|
return EditKind::Unchanged;
|
||||||
}
|
}
|
||||||
|
// A number is never a typo: punctuation or a space inside it changes its value.
|
||||||
|
if numbers(old) != numbers(new) {
|
||||||
|
return EditKind::Substantive;
|
||||||
|
}
|
||||||
if skeleton(old) == skeleton(new) {
|
if skeleton(old) == skeleton(new) {
|
||||||
return EditKind::Typographic;
|
return EditKind::Typographic;
|
||||||
}
|
}
|
||||||
@@ -198,6 +207,14 @@ mod tests {
|
|||||||
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_number_is_never_a_typo() {
|
||||||
|
// Same letters and digits, different values (independent review, 2026-10-21).
|
||||||
|
assert_eq!(classify("au moins 50 voix", "au moins 5,0 voix"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("1 5 jours", "15 jours"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn misspelt_words() {
|
fn misspelt_words() {
|
||||||
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
|
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
|
||||||
|
|||||||
@@ -129,8 +129,12 @@ pub async fn invite(
|
|||||||
if label.is_empty() || label.chars().count() > 60 {
|
if label.is_empty() || label.chars().count() > 60 {
|
||||||
return Err(AppError::Invalid("label"));
|
return Err(AppError::Invalid("label"));
|
||||||
}
|
}
|
||||||
if let Some(id) = &b.identity_id {
|
// A new link for an existing person is for invitation identities only: a link
|
||||||
auth::load_identity(&state, id).await?;
|
// to a Ğ1 identity would let an administrator open a session in its name.
|
||||||
|
if let Some(id) = &b.identity_id
|
||||||
|
&& auth::load_identity(&state, id).await?.kind != "invite"
|
||||||
|
{
|
||||||
|
return Err(AppError::Invalid("identity_kind"));
|
||||||
}
|
}
|
||||||
let token =
|
let token =
|
||||||
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
||||||
|
|||||||
@@ -169,7 +169,16 @@ pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
|
|||||||
/// Only local paths, never `//host` nor a scheme.
|
/// Only local paths, never `//host` nor a scheme.
|
||||||
fn safe_return(raw: Option<&str>) -> String {
|
fn safe_return(raw: Option<&str>) -> String {
|
||||||
match raw {
|
match raw {
|
||||||
Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && p.len() < 512 => p.to_owned(),
|
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
|
||||||
|
Some(p)
|
||||||
|
if p.starts_with('/')
|
||||||
|
&& !p.starts_with("//")
|
||||||
|
&& !p.contains('\\')
|
||||||
|
&& !p.chars().any(char::is_control)
|
||||||
|
&& p.len() < 512 =>
|
||||||
|
{
|
||||||
|
p.to_owned()
|
||||||
|
}
|
||||||
_ => "/groupes".to_owned(),
|
_ => "/groupes".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,18 +333,33 @@ struct InvitationRow {
|
|||||||
|
|
||||||
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
||||||
let hash = util::sha256_hex(body.token.trim());
|
let hash = util::sha256_hex(body.token.trim());
|
||||||
|
let now = util::now();
|
||||||
|
// Reserve the link first: of two simultaneous acceptances, one only goes on.
|
||||||
|
let reserved =
|
||||||
|
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if reserved.rows_affected() != 1 {
|
||||||
|
return Err(AppError::Invalid("invitation_invalid"));
|
||||||
|
}
|
||||||
let inv = sqlx::query_as::<_, InvitationRow>(
|
let inv = sqlx::query_as::<_, InvitationRow>(
|
||||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations
|
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
|
||||||
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
|
|
||||||
)
|
)
|
||||||
.bind(&hash)
|
.bind(&hash)
|
||||||
.bind(util::now())
|
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
||||||
|
|
||||||
let identity_id = match inv.identity_id.clone() {
|
let identity_id = match inv.identity_id.clone() {
|
||||||
Some(existing) => existing,
|
Some(existing) => {
|
||||||
|
if load_identity(&state, &existing).await?.kind != "invite" {
|
||||||
|
return Err(AppError::Invalid("invitation_invalid"));
|
||||||
|
}
|
||||||
|
existing
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
let id = format!("inv:{}", util::id());
|
let id = format!("inv:{}", util::id());
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -355,16 +379,11 @@ pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<I
|
|||||||
}
|
}
|
||||||
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
|
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
|
||||||
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
|
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
|
||||||
let used =
|
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
|
||||||
sqlx::query("UPDATE invitations SET used_at = ?, identity_id = ? WHERE token_hash = ? AND used_at IS NULL")
|
|
||||||
.bind(util::now())
|
|
||||||
.bind(&identity_id)
|
.bind(&identity_id)
|
||||||
.bind(&inv.token_hash)
|
.bind(&inv.token_hash)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if used.rows_affected() != 1 {
|
|
||||||
return Err(AppError::Invalid("invitation_invalid"));
|
|
||||||
}
|
|
||||||
let session = open_session(&state, &identity_id).await?;
|
let session = open_session(&state, &identity_id).await?;
|
||||||
let me = load_identity(&state, &identity_id).await?;
|
let me = load_identity(&state, &identity_id).await?;
|
||||||
let mut res = Json(json!({ "me": me })).into_response();
|
let mut res = Json(json!({ "me": me })).into_response();
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||||
|
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||||
|
let has = granted(state, &group.slug, &user.id).await?;
|
||||||
|
Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GroupView {
|
struct GroupView {
|
||||||
@@ -523,7 +529,9 @@ pub async fn document(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let publication = publication_status(&state, &id).await?;
|
let publication = publication_status(&state, &id).await?;
|
||||||
|
let can_vote = may_vote(&state, &user, &st.group).await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
|
"canVote": can_vote,
|
||||||
"doc": {
|
"doc": {
|
||||||
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||||
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||||
|
|||||||
@@ -123,9 +123,20 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn app(state: AppState) -> Router {
|
pub fn app(state: AppState) -> Router {
|
||||||
Router::new()
|
use axum::http::{HeaderName, HeaderValue, header};
|
||||||
.nest("/api", api_router())
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
.fallback_service(web::service(&state.cfg.web_dir))
|
// Neighbouring sites under *.librodrome.org are same-site: none may frame us.
|
||||||
|
let security: [(HeaderName, &'static str); 4] = [
|
||||||
|
(header::X_FRAME_OPTIONS, "DENY"),
|
||||||
|
(header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
|
||||||
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||||
|
(header::REFERRER_POLICY, "same-origin"),
|
||||||
|
];
|
||||||
|
let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir));
|
||||||
|
for (name, value) in security {
|
||||||
|
router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)));
|
||||||
|
}
|
||||||
|
router
|
||||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
|
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
|
||||||
.layer(tower_http::compression::CompressionLayer::new())
|
.layer(tower_http::compression::CompressionLayer::new())
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
|
|||||||
@@ -594,6 +594,10 @@ pub async fn vote(
|
|||||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
return Err(AppError::Conflict("not_votable"));
|
return Err(AppError::Conflict("not_votable"));
|
||||||
}
|
}
|
||||||
|
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
|
||||||
|
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
|
||||||
|
return Err(AppError::Conflict("wot_members_only"));
|
||||||
|
}
|
||||||
match b.choice.as_deref() {
|
match b.choice.as_deref() {
|
||||||
Some(choice @ ("for" | "against")) => {
|
Some(choice @ ("for" | "against")) => {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ async fn anonymous_sees_nothing_and_csrf_is_enforced() {
|
|||||||
.expect("request");
|
.expect("request");
|
||||||
let res = c.router.clone().oneshot(req).await.expect("response");
|
let res = c.router.clone().oneshot(req).await.expect("response");
|
||||||
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||||
|
// No neighbour may frame the product.
|
||||||
|
assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..]));
|
||||||
|
assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -377,6 +380,38 @@ async fn access_management_is_for_administrators_only() {
|
|||||||
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
|
||||||
|
let (mut admin, _) = Client::new().await;
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let (_, v) = admin
|
||||||
|
.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] })))
|
||||||
|
.await;
|
||||||
|
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||||
|
let mut invited = admin.with(None);
|
||||||
|
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
// She reads the text and may propose, but her vote would not be a web-of-trust vote.
|
||||||
|
let id = doc_id(&mut invited, "monnaie-libre").await;
|
||||||
|
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(doc["canVote"], false);
|
||||||
|
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||||
|
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||||
|
assert_eq!(v["error"], "wot_members_only");
|
||||||
|
// In the test group, every member votes.
|
||||||
|
let essai = doc_id(&mut admin, "essai").await;
|
||||||
|
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||||
|
assert_eq!(d["canVote"], true);
|
||||||
|
// A new link may only target an invitation identity, never another kind.
|
||||||
|
let (_, me) = admin.call("GET", "/api/me", None).await;
|
||||||
|
let dev_id = me["me"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let (s, _) =
|
||||||
|
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn feedback_reaches_administrators() {
|
async fn feedback_reaches_administrators() {
|
||||||
let (mut user, _) = Client::new().await;
|
let (mut user, _) = Client::new().await;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
//! - a group opens only to its members; Monnaie libre's members are the
|
//! - a group opens only to its members; Monnaie libre's members are the
|
||||||
//! members of the Ğ1 web of trust, the others are granted one by one;
|
//! members of the Ğ1 web of trust, the others are granted one by one;
|
||||||
//! - everything is open to a member except access management, which belongs
|
//! - everything is open to a member except access management, which belongs
|
||||||
//! to trustinWallet's administrators.
|
//! to trustinWallet's administrators;
|
||||||
|
//! - where the electorate is the web of trust (Monnaie libre), only its members
|
||||||
|
//! vote (2026-10-21, independent review: one person, one vote).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -99,6 +101,18 @@ pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool {
|
|||||||
is_member(identity, group, granted)
|
is_member(identity, group, granted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can this identity vote in the group? Where the electorate is the Ğ1 web of
|
||||||
|
/// trust (policy `G1Members`), only its members vote: the threshold is computed
|
||||||
|
/// on the size of the web, and an invitation identity next to a Ğ1 identity
|
||||||
|
/// would give one person two votes. Development identities stand in for members
|
||||||
|
/// (local only: the dev login is never enabled in production).
|
||||||
|
pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool {
|
||||||
|
opens(identity, group, granted)
|
||||||
|
&& (group.policy != GroupPolicy::G1Members
|
||||||
|
|| identity.kind == IdentityKind::Dev
|
||||||
|
|| (identity.kind == IdentityKind::G1 && identity.g1_member))
|
||||||
|
}
|
||||||
|
|
||||||
/// Access management stays with trustinWallet's administrators.
|
/// Access management stays with trustinWallet's administrators.
|
||||||
pub fn manages_access(identity: &Identity) -> bool {
|
pub fn manages_access(identity: &Identity) -> bool {
|
||||||
identity.admin
|
identity.admin
|
||||||
@@ -176,6 +190,18 @@ mod tests {
|
|||||||
assert!(!opens(&admin, &group("essai"), false));
|
assert!(!opens(&admin, &group("essai"), false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_wot_members_vote_where_the_electorate_is_the_web_of_trust() {
|
||||||
|
let ml = group("monnaie-libre");
|
||||||
|
assert!(votes_in(&who(IdentityKind::G1, true, false), &ml, false));
|
||||||
|
let invited = who(IdentityKind::Invite, false, true);
|
||||||
|
assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose");
|
||||||
|
assert!(!votes_in(&invited, &ml, true), "but its votes would not be web-of-trust votes");
|
||||||
|
assert!(!votes_in(&who(IdentityKind::G1, false, false), &ml, true), "a former member reads, does not vote");
|
||||||
|
assert!(votes_in(&invited, &group("essai"), true), "managed groups: every member votes");
|
||||||
|
assert!(!votes_in(&invited, &group("essai"), false));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_atelier_du_canal() {
|
fn no_atelier_du_canal() {
|
||||||
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
|
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté.
|
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté.
|
||||||
|
|
||||||
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) :
|
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) :
|
||||||
|
- **jamais coquille** si un nombre change, même par la ponctuation (« 50 » → « 5,0 ») ;
|
||||||
- **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si
|
- **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si
|
||||||
au plus 2 mots diffèrent de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas,
|
au plus 2 mots diffèrent de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas,
|
||||||
jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ;
|
jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
|
|||||||
en-tête anti-CSRF sur toute écriture.
|
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
|
- Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du
|
||||||
service trustinWallet ; le serveur ne décide jamais seul.
|
service trustinWallet ; le serveur ne décide jamais seul.
|
||||||
|
- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
|
||||||
|
seuls ses membres votent ; une identité d'invitation y lit et propose. Un nouveau lien ne
|
||||||
|
vise jamais une identité Ğ1 (un administrateur ouvrirait une session à sa place).
|
||||||
- Premier administrateur : un lien d'invitation écrit au premier démarrage dans le volume de
|
- Premier administrateur : un lien d'invitation écrit au premier démarrage dans le volume de
|
||||||
données (0600), jamais dans les journaux.
|
données (0600), jamais dans les journaux.
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p
|
|||||||
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
|
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
|
||||||
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
|
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
|
||||||
|
|
||||||
Le classement est prudent : un chiffre ou un mot de négation (ne, pas, jamais, personne…)
|
Le classement est prudent : un nombre changé (même par une virgule ou une espace : « 50 » →
|
||||||
touché rend la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`.
|
« 5,0 ») ou un mot de négation touché (ne, pas, jamais, personne…) rend la modification « de
|
||||||
|
fond ». Moteur : `crates/ld-engine/src/edit.rs`.
|
||||||
|
|
||||||
### Deux articles qui se contredisent
|
### Deux articles qui se contredisent
|
||||||
|
|
||||||
@@ -119,6 +120,11 @@ corriger, signaler) **sauf la gestion des accès**, réservée aux administrateu
|
|||||||
(`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication.
|
(`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication.
|
||||||
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
|
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
|
||||||
|
|
||||||
|
**Une personne, une voix.** Dans Monnaie libre, dont l'électorat W est la toile de confiance,
|
||||||
|
**seuls les membres de la toile votent** (connexion Ğ1). Une identité d'invitation y lit,
|
||||||
|
propose, corrige et signale, mais ne vote pas : sinon la même personne, invitée puis connectée
|
||||||
|
avec sa clé, voterait deux fois. Dans les groupes gérés (essai, Axiom…), tout membre vote.
|
||||||
|
|
||||||
## Le groupe d'essai
|
## Le groupe d'essai
|
||||||
|
|
||||||
Caché, plancher C = 1, sans publication : on y éprouve tout (adoption, coquilles, découpage)
|
Caché, plancher C = 1, sans publication : on y éprouve tout (adoption, coquilles, découpage)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
|
|||||||
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
||||||
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
||||||
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
||||||
|
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
|
||||||
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
||||||
|
|
||||||
## Au-delà de la liste
|
## Au-delà de la liste
|
||||||
@@ -40,3 +41,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|
|||||||
| Date | Qui | Où | Contrôles | Résultat |
|
| Date | Qui | Où | Contrôles | Résultat |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts |
|
| 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 |
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ ce navigateur pour 30 jours.
|
|||||||
1. Ouvre ton lien d'invitation → tu arrives connectée.
|
1. Ouvre ton lien d'invitation → tu arrives connectée.
|
||||||
2. Les contrôles qui écrivent (voter, corriger, découper) se font dans le **groupe d'essai** :
|
2. Les contrôles qui écrivent (voter, corriger, découper) se font dans le **groupe d'essai** :
|
||||||
il est caché, une seule voix « pour » suffit à faire passer une proposition, et rien n'en
|
il est caché, une seule voix « pour » suffit à faire passer une proposition, et rien n'en
|
||||||
sort. Le texte de Monnaie libre, lui, est le vrai : lis-le, ne vote que pour de vrai.
|
sort. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis,
|
||||||
3. Passe les contrôles C1–C10 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne.
|
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.
|
||||||
4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce
|
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.
|
que tu attendais, ce qui s'est passé. La page, la langue et la taille de l'écran sont jointes.
|
||||||
|
|
||||||
|
|||||||
@@ -133,3 +133,25 @@ test('C10 — trustinWallet : la gestion des accès est réservée', async ({ br
|
|||||||
await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click();
|
await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click();
|
||||||
await expect(yvv.locator('.link code')).toContainText('/invitation#');
|
await expect(yvv.locator('.link code')).toContainText('/invitation#');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent', async ({ browser }) => {
|
||||||
|
const yvv = await person(browser, 'Yvv', true);
|
||||||
|
const r = await yvv.request.post('/api/access/invitations', {
|
||||||
|
data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] },
|
||||||
|
headers: { 'x-requested-with': 'libredecision' },
|
||||||
|
});
|
||||||
|
const { link } = (await r.json()) as { link: string };
|
||||||
|
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
||||||
|
const invited = await ctx.newPage();
|
||||||
|
await invited.goto(`/invitation#${link.split('#')[1]}`);
|
||||||
|
await expect(invited).toHaveURL(/\/groupes$/);
|
||||||
|
await invited.goto(DOC);
|
||||||
|
await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click();
|
||||||
|
const panel = invited.locator('aside.panel');
|
||||||
|
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
|
||||||
|
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||||
|
// In the test group, the same person votes.
|
||||||
|
await invited.goto(TEST_DOC);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -255,7 +255,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
||||||
|
|
||||||
{#if votable}
|
{#if votable && !view?.canVote}
|
||||||
|
<p class="muted">{t('vote.wotOnly')}</p>
|
||||||
|
{:else if votable}
|
||||||
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
|
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
|
||||||
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
||||||
<span class="label">{t('vote.yours')}</span>
|
<span class="label">{t('vote.yours')}</span>
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export const en: Messages = {
|
|||||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||||
'vote.closed': 'This version is no longer open for voting.',
|
'vote.closed': 'This version is no longer open for voting.',
|
||||||
|
'vote.wotOnly': 'Here, only members of the Ğ1 web of trust vote: sign in with your Ğ1 key to vote. You can read, propose and flag a contradiction.',
|
||||||
|
|
||||||
'inertia.title': 'Inertia',
|
'inertia.title': 'Inertia',
|
||||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
||||||
@@ -309,6 +310,7 @@ export const en: Messages = {
|
|||||||
'error.last_admin': 'At least one administrator must remain.',
|
'error.last_admin': 'At least one administrator must remain.',
|
||||||
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
|
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
|
||||||
'error.invitation_invalid': 'This invitation link is no longer valid.',
|
'error.invitation_invalid': 'This invitation link is no longer valid.',
|
||||||
|
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
|
||||||
'error.retry': 'Try again',
|
'error.retry': 'Try again',
|
||||||
|
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export const es: Messages = {
|
|||||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||||
'vote.closed': 'Esta versión ya no está en votación.',
|
'vote.closed': 'Esta versión ya no está en votación.',
|
||||||
|
'vote.wotOnly': 'Aquí solo votan los miembros de la red de confianza Ğ1: conéctate con tu clave Ğ1 para votar. Puedes leer, proponer y señalar una contradicción.',
|
||||||
|
|
||||||
'inertia.title': 'Inercia',
|
'inertia.title': 'Inercia',
|
||||||
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
||||||
@@ -309,6 +310,7 @@ export const es: Messages = {
|
|||||||
'error.last_admin': 'Debe quedar al menos un administrador.',
|
'error.last_admin': 'Debe quedar al menos un administrador.',
|
||||||
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
|
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
|
||||||
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
|
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
|
||||||
|
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
|
||||||
'error.retry': 'Reintentar',
|
'error.retry': 'Reintentar',
|
||||||
|
|
||||||
'common.cancel': 'Cancelar',
|
'common.cancel': 'Cancelar',
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export const fr = {
|
|||||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||||
'vote.closed': 'Cette version ne se vote plus.',
|
'vote.closed': 'Cette version ne se vote plus.',
|
||||||
|
'vote.wotOnly': 'Ici, seuls les membres de la toile de confiance Ğ1 votent : connecte-toi avec ta clé Ğ1 pour voter. Tu peux lire, proposer et signaler une contradiction.',
|
||||||
|
|
||||||
'inertia.title': 'Inertie',
|
'inertia.title': 'Inertie',
|
||||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
||||||
@@ -312,6 +313,7 @@ export const fr = {
|
|||||||
'error.last_admin': 'Il faut garder au moins un administrateur.',
|
'error.last_admin': 'Il faut garder au moins un administrateur.',
|
||||||
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
|
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
|
||||||
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
|
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
|
||||||
|
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
|
||||||
'error.retry': 'Réessayer',
|
'error.retry': 'Réessayer',
|
||||||
|
|
||||||
'common.cancel': 'Annuler',
|
'common.cancel': 'Annuler',
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface Publication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DocView {
|
export interface DocView {
|
||||||
|
/** trustinWallet: where the electorate is the web of trust, only its members vote. */
|
||||||
|
canVote: boolean;
|
||||||
doc: {
|
doc: {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user