Compare commits

...
5 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 dc2de0947c v3 : relecture de decbc0b — lien annulé retiré de la page, liens annulés avant les sessions, pluriel espagnol
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:26:56 +02:00
YvvandClaude Opus 5.5 decbc0ba7b v3 : relecture de cf634c2 — renouvellement sans effet sur la réponse, lien en attente fermé avec les sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:02:26 +02:00
YvvandClaude Opus 5.5 cf634c25d2 v3 : une session dure jusqu'à ce qu'on la ferme, l'administration ferme celles d'une identité
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:45:26 +02:00
YvvandClaude Opus 5.5 422b45ab75 HANDOFF : première publication des textes (4cc0fdd), rappels de reprise
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 03:57:58 +02:00
YvvandClaude Opus 5.5 8ec0b48e8f HANDOFF : 744d157 en ligne, incident de l'agent CI, restes des relectures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:50:17 +02:00
12 changed files with 310 additions and 29 deletions
+37 -16
View File
@@ -1,17 +1,31 @@
# HANDOFF — libreDecision
> Dernière mise à jour : 2026-10-21, session orch du chantier 1.
> Dernière mise à jour : 2026-10-23, session orch du chantier 1.
## 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 est sur
maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). Sur la branche du chantier,
pas encore en ligne : les points des septième, huitième et neuvième relectures, les **liens d'équipe**
(demandés par Yvv le 2026-10-21) et la publication vers git.open.us.org.
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org,
version `744d157` depuis le 2026-10-22 (pipeline Woodpecker n° 32 ; avant : `fa8e8dc`, n° 30) ;
la maquette est sur maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). En ligne :
liens d'équipe, nouveau lien de connexion, noms uniques et mention « sur invitation », règles de
coquilles des relectures 7 à 10, publication vers `yvv/decision-depot-textes-reference` (état et
« Publier maintenant » dans `/acces`). Première publication le 2026-10-22 à 03 h 22 (commit
`4cc0fdd` : `monnaie-libre/acte-engagement-certification/` fr, en, es, document.json, README).
**Prêt, pas encore en ligne** (2026-10-23) : les sessions durent **jusqu'à ce qu'on les ferme**
(400 jours recomptés à chaque visite, au lieu de 30 jours fixes) ; dans `/acces`, « Fermer ses
sessions » par identité (appareil perdu, départ), jamais celle d'où l'on agit, avec le lien de
connexion encore en attente pour elle. Raison : une personne invitée n'a pas de clé Ğ1 pour
revenir, une session expirée lui coûtait un nouveau lien. Les sessions ouvertes pour 30 jours
passent à 400 jours à leur prochaine visite.
Incident du 2026-10-21 : Docker a redémarré sur sonic à 21 h 47 ; l'agent Woodpecker
`aya-woodpeckerci-local-agent-1` gardait l'ancienne socket (pipeline n° 31 : « Cannot connect to
the Docker daemon », rien construit). Remède : `docker restart aya-woodpeckerci-local-agent-1`
(fait avec l'accord d'Yvv), puis « Redémarrer » le pipeline dans l'interface Woodpecker.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 69 tests dont 16 scénarios d'API et la
- Boucle : Rust (fmt, clippy `-D warnings`, 71 tests dont 17 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
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
@@ -34,15 +48,9 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture
## Ce qui attend Yvv
1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`.
2. **Dépôt des textes** : `yvv/decision-depot-textes-reference` sur **git.open.us.org**, créé
par Yvv le 2026-10-21 (privé pour l'instant : les applis ne pourront le lire en brut qu'une
fois public) — un dépôt séparé du code, pour que la clé du serveur n'écrive jamais dans
`yvv/decision` et que le dépôt se transfère d'un bloc (Yvv : « pour l'instant sur open.us ; on
fera un dépôt pour l'ouverture, un transfert, et le dépôt officiel pour les textes en
vigueur »). Reste à y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique s'affiche dans trustinWallet,
`/acces`, section Publication). Le serveur fait lui-même le premier envoi ; en cas d'échec il
réessaie avec un délai croissant (1 min, 2, 4… jusqu'à 6 h), repart d'une minute à chaque
redémarrage, et `/acces` → Publication montre l'état et offre « Publier maintenant ». Changer de dépôt plus tard : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`.
2. ~~Dépôt des textes~~ : fait le 2026-10-22 — `yvv/decision-depot-textes-reference` (privé), clé de
déploiement en écriture ajoutée par Yvv. À l'ouverture : rendre le dépôt public (Paramètres ›
Dépôt), ou le transférer ; changer de dépôt : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`.
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un
seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe**
(`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une
@@ -52,7 +60,20 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture
## Pour reprendre
- **Premier geste de la session suivante** (Yvv, 2026-10-22 : « redonne cette consigne quand je
relance ce chantier ») : rappeler à Yvv le compte git.open.us.org de Chiara (collaboratrice
de `yvv/decision`) et les deux choix en attente, la licence du code et le texte F1.2 du seed.
- Lire `docs/chantiers/acte-certification/CHANTIER.md` puis `CONTROLES.md`.
- Les retours de Chiara arrivent dans `/acces` → Retours : chacun est un contrôle à repasser.
- Quand les identifiants connect arrivent : `DEMANDE-CONNECT.md` § « À la réponse ».
- Chantier suivant (après la mise en production et les retours) : Acte d'engagement forgeron.
- Restes des relectures, non bloquants, pour la prochaine série :
- `publish.rs` : motif `ls-remote` en `refs/heads/<branche>` (une branche `x/main` le trompe) ;
« Dernière publication » rattachée au dépôt affiché ; compter les documents en attente, pas
les lignes de la file ;
- mention « sur invitation » : en `kind <> 'g1'` (tout type futur) ; tests de ce qui s'affiche
(révision visible, signalement, bout en bout) ; auteur des retours dans `/acces` avec son type ;
- noms : sosies latins étendus et ligatures (ı, ł, œ, ß…), invitations créées par un
administrateur sans contrôle de nom ;
- `sbom-publish` échoue (curl 60 : certificat du serveur Dependency-Track refusé) — étape en
`failure: ignore`, déjà le cas avant la v3.
+54 -1
View File
@@ -7,6 +7,7 @@ use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use serde::Deserialize;
use serde_json::json;
@@ -14,7 +15,11 @@ fn admin_only(user: &IdentityRow) -> AppResult<()> {
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
}
pub async fn overview(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
pub async fn overview(
State(state): State<AppState>,
headers: HeaderMap,
User(user): User,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
#[derive(sqlx::FromRow)]
struct Member {
@@ -34,6 +39,14 @@ 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?;
// Open sessions, but the one this page is read with.
let sessions: Vec<(String, i64)> = sqlx::query_as(
"SELECT identity_id, COUNT(*) FROM sessions WHERE expires_at > ? AND token_hash != ? GROUP BY identity_id",
)
.bind(util::now())
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
.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)
@@ -63,6 +76,7 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
"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()),
"sessions": sessions.iter().find(|(id, _)| id == &i.id).map_or(0, |(_, n)| *n),
})).collect::<Vec<_>>(),
"teamLinks": crate::team::list(&state).await?,
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
@@ -207,6 +221,45 @@ pub async fn set_admin(
Ok(Json(json!({ "admin": b.admin })))
}
/// A lost device, a person who left: every session of this identity closes,
/// but the one this request comes with, and so does a sign-in link still
/// waiting for it (it may sit on the lost device).
pub async fn close_sessions(
State(state): State<AppState>,
headers: HeaderMap,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
auth::load_identity(&state, &id).await?;
let current = auth::session_hash(&state, &headers).unwrap_or_default();
// The waiting link first: opened in between, it would open a session that stays.
let links = sqlx::query(
"UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?",
)
.bind(util::now())
.bind(&id)
.bind(util::now())
.execute(&state.db)
.await?
.rows_affected();
// Counted as the overview counts them; the expired ones go too.
let open = sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ? AND expires_at > ?")
.bind(&id)
.bind(&current)
.bind(util::now())
.execute(&state.db)
.await?
.rows_affected();
sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
.bind(&id)
.bind(&current)
.execute(&state.db)
.await?;
tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed");
Ok(Json(json!({ "closed": open, "links": links })))
}
#[derive(Deserialize)]
pub struct FeedbackBody {
route: String,
+70 -3
View File
@@ -39,7 +39,9 @@ fn session_cookie(state: &AppState) -> &'static str {
fn oauth_cookie(state: &AppState) -> &'static str {
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
}
const SESSION_DAYS: i64 = 30;
/// A session lasts until it is closed (sign-out, or an administrator): 400 days
/// is the longest a browser keeps a cookie, and each visit starts them again.
const SESSION_DAYS: i64 = 400;
const INVITATION_DAYS: i64 = 14;
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
@@ -178,8 +180,48 @@ pub async fn config(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login }))
}
pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
Json(json!({ "me": viewer }))
/// The hash of the session this request comes with, if any.
pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<String> {
cookie_value(headers, session_cookie(state)).map(|token| util::sha256_hex(&token))
}
/// Who is signed in. Once a day at most, the visit starts the session's days
/// again, in the database and in the browser. A renewal that fails is logged,
/// never answered as signed out.
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> Response {
let mut res = Json(json!({ "me": viewer })).into_response();
if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) {
match renew(&state, &token).await {
Ok(true) => {
res.headers_mut()
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400));
let seen = sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
.bind(util::now())
.bind(&me.id)
.execute(&state.db)
.await;
if let Err(e) = seen {
tracing::warn!(error = %e, "last visit not recorded");
}
}
Ok(false) => {}
Err(e) => tracing::warn!(error = %e, "session not renewed"),
}
}
res
}
/// Whether the session was renewed: still open, and renewed more than a day ago.
async fn renew(state: &AppState, token: &str) -> AppResult<bool> {
let renewed =
sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?")
.bind(util::in_days(SESSION_DAYS))
.bind(util::sha256_hex(token))
.bind(util::now())
.bind(util::in_days(SESSION_DAYS - 1))
.execute(&state.db)
.await?;
Ok(renewed.rows_affected() == 1)
}
/// Only local paths, never `//host` nor a scheme.
@@ -543,6 +585,31 @@ mod tests {
assert_eq!(safe_return(None), "/groupes");
}
#[tokio::test]
async fn a_session_over_is_never_renewed() {
let db = crate::open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(crate::Config::for_tests(), db).await.expect("state");
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES ('dev:a', 'dev', 'A', 0, 0, ?)")
.bind(util::now())
.execute(&state.db)
.await
.expect("identity");
for (token, expires) in [("over", util::in_seconds(-1)), ("open", util::in_days(10))] {
sqlx::query(
"INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, 'dev:a', ?, ?)",
)
.bind(util::sha256_hex(token))
.bind(util::now())
.bind(expires)
.execute(&state.db)
.await
.expect("session");
}
assert!(!renew(&state, "over").await.expect("renew"));
assert!(renew(&state, "open").await.expect("renew"));
assert!(!renew(&state, "open").await.expect("renew"), "once a day at most");
}
#[test]
fn cookie_parsing() {
let mut h = HeaderMap::new();
+1
View File
@@ -127,6 +127,7 @@ pub fn api_router() -> Router<AppState> {
.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/identities/{id}/sessions", delete(access::close_sessions))
.route("/access/feedback", get(access::feedback_list))
.route("/access/feedback/{id}", post(access::feedback_status))
.layer(middleware::from_fn(csrf_guard))
+103 -2
View File
@@ -10,6 +10,8 @@ use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
/// The `Set-Cookie` of the last answer, whole.
set_cookie: Option<String>,
}
impl Client {
@@ -17,11 +19,11 @@ impl Client {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
}
fn with(&self, cookie: Option<String>) -> Self {
Self { router: self.router.clone(), cookie }
Self { router: self.router.clone(), cookie, set_cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
@@ -36,6 +38,7 @@ impl Client {
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
self.set_cookie = res.headers().get(header::SET_COOKIE).map(|v| v.to_str().expect("cookie").to_owned());
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
@@ -470,6 +473,104 @@ async fn access_management_is_for_administrators_only() {
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off")));
}
#[tokio::test]
async fn a_session_lasts_until_it_is_closed() {
const FOUR_HUNDRED_DAYS: &str = "Max-Age=34560000";
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
// Renewed once a day at most: a visit right after changes nothing.
chiara.call("GET", "/api/me", None).await;
assert_eq!(chiara.set_cookie, None);
// Ten days left: the next visit starts the 400 days again, here and in the browser.
sqlx::query("UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+10 days')")
.execute(&state.db)
.await
.expect("age the session");
sqlx::query("UPDATE identities SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-10 days')")
.execute(&state.db)
.await
.expect("and the last visit");
let (_, v) = chiara.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Chiara");
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
let far: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sessions WHERE expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+399 days')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(far, 1);
let seen: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM identities WHERE id = 'dev:chiara' AND last_seen_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 minute')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(seen, 1, "the renewal says when the person was last seen");
// A session already over does not come back.
let mut old = chiara.with(None);
old.login("Chiara", false).await;
let old_cookie = old.cookie.clone();
sqlx::query(
"UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')
WHERE rowid = (SELECT MAX(rowid) FROM sessions)",
)
.execute(&state.db)
.await
.expect("end the session");
let (_, v) = old.call("GET", "/api/me", None).await;
assert_eq!((v["me"].clone(), old.set_cookie.clone()), (Value::Null, None));
assert_eq!(old.cookie, old_cookie);
// Until an administrator closes them: a lost device, a person who left.
let mut phone = chiara.with(None);
phone.login("Chiara", false).await;
let (s, _) = chiara.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let mut admin = chiara.with(None);
admin.login("Yvv", true).await;
let sessions_of = |ov: &Value, id: &str| {
ov["identities"].as_array().expect("i").iter().find(|i| i["id"] == id).expect("identity")["sessions"].clone()
};
// A sign-in link sent to the lost device goes with the sessions.
sqlx::query(
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
VALUES ('waiting', 'Chiara', 0, '[]', 'dev:yvv', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+14 days'), 'dev:chiara')",
)
.execute(&state.db)
.await
.expect("a sign-in link waiting");
let (_, ov) = admin.call("GET", "/api/access", None).await;
assert_eq!(sessions_of(&ov, "dev:chiara"), 2, "the session already over is not counted");
assert_eq!(sessions_of(&ov, "dev:yvv"), 0, "the session this page is read with is not counted");
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
assert_eq!((s, v["closed"].as_u64(), v["links"].as_u64()), (StatusCode::OK, Some(2), Some(1)));
for c in [&mut chiara, &mut phone] {
let (_, v) = c.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
}
let waiting: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM invitations WHERE token_hash = 'waiting' AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(waiting, 0);
// An administrator's own: the others close, this one stays open.
let mut laptop = admin.with(None);
laptop.login("Yvv", true).await;
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:yvv/sessions", None).await;
assert_eq!((s, v["closed"].as_u64()), (StatusCode::OK, Some(1)));
let (_, v) = admin.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Yvv");
let (_, v) = laptop.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
let (s, _) = admin.call("DELETE", "/api/access/identities/nobody/sessions", None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_team_link_makes_one_identity_per_opening() {
let (mut admin, _) = Client::new().await;
+9 -2
View File
@@ -26,8 +26,15 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
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.
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https (`__Host-`), jeton haché en base ;
en-tête anti-CSRF sur toute écriture. Une session dure **jusqu'à ce qu'on la ferme** : 400 jours
(ce qu'un navigateur garde au plus), recomptés à chaque visite, une fois par jour au plus. On la
ferme en se déconnectant. Un administrateur peut aussi, dans trustinWallet, fermer les sessions
d'une identité (appareil perdu, départ), sauf celle d'où il agit ; il annule du même geste le
lien de connexion encore en attente pour cette identité. Une personne non administratrice qui
perd un appareil le demande à un administrateur. Jusqu'au 2026-10-23, les sessions duraient
30 jours fixes ; on a changé parce qu'une personne invitée n'a pas de clé Ğ1 pour revenir, et
qu'une session expirée lui coûtait un nouveau lien.
- 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.
- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
@@ -17,8 +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. 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
ce navigateur. Elle reste ouverte tant que tu reviens au moins une fois par an, jusqu'à ce que
tu te déconnectes ou qu'un administrateur la ferme (appareil perdu, par exemple). 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
+6
View File
@@ -304,6 +304,12 @@ export const en: Messages = {
'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.sessions.close': 'Close their sessions ({n})',
'access.sessions.closeMine': 'Close my other sessions ({n})',
'access.sessions.closed.one': '{n} session closed.',
'access.sessions.closed.other': '{n} sessions closed.',
'access.sessions.links.one': 'The sign-in link still waiting is cancelled.',
'access.sessions.links.other': 'The {n} sign-in links still waiting are cancelled.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
+6
View File
@@ -304,6 +304,12 @@ export const es: Messages = {
'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.sessions.close': 'Cerrar sus sesiones ({n})',
'access.sessions.closeMine': 'Cerrar mis otras sesiones ({n})',
'access.sessions.closed.one': '{n} sesión cerrada.',
'access.sessions.closed.other': '{n} sesiones cerradas.',
'access.sessions.links.one': 'El enlace de conexión pendiente queda anulado.',
'access.sessions.links.other': 'Los {n} enlaces de conexión pendientes quedan anulados.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitación',
'access.kind.dev': 'dev',
+6
View File
@@ -307,6 +307,12 @@ export const fr = {
'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.sessions.close': 'Fermer ses sessions ({n})',
'access.sessions.closeMine': 'Fermer mes autres sessions ({n})',
'access.sessions.closed.one': '{n} session fermée.',
'access.sessions.closed.other': '{n} sessions fermées.',
'access.sessions.links.one': 'Le lien de connexion encore en attente est annulé.',
'access.sessions.links.other': 'Les {n} liens de connexion encore en attente sont annulés.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
+2 -2
View File
@@ -41,9 +41,9 @@ class I18n {
return raw.replace(/\{(\w+)\}/g, (_, k: string) => (k in params ? String(params[k]) : `{${k}}`));
};
/** `<key>.one` / `<key>.other` — French and Spanish use « one » for 0 and 1. */
/** `<key>.one` / `<key>.other` — French says « 0 session », English and Spanish « 0 sessions ». */
plural = (key: string, n: number, params: Record<string, string | number> = {}): string => {
const one = this.lang === 'en' ? n === 1 : n <= 1;
const one = this.lang === 'fr' ? n <= 1 : n === 1;
return this.t(`${key}.${one ? 'one' : 'other'}` as Key, { n, ...params });
};
+13 -1
View File
@@ -16,7 +16,7 @@
status?: { pending: number; lastError: string | null; lastPublishedAt: string | null; lastCommit: string | null };
};
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null; sessions: number }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
teamLinks: TeamLink[];
}
@@ -77,6 +77,17 @@
});
}
// A lost device, a person who left: their sessions close (never the one in use here),
// and so does a sign-in link still waiting for them, even one shown just now.
async function closeSessions(id: string) {
await act(async () => {
const r = await api.del<{ closed: number; links: number }>(`/access/identities/${encodeURIComponent(id)}/sessions`);
if (relinked?.id === id) relinked = null;
const links = r.links > 0 ? ` ${i18n.plural('access.sessions.links', r.links)}` : '';
toasts.show(i18n.plural('access.sessions.closed', r.closed) + links);
});
}
async function status(id: number, s: 'seen' | 'done') {
await api.post(`/access/feedback/${id}`, { status: s });
await fb.load();
@@ -165,6 +176,7 @@
</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}
{#if i.sessions > 0}<button class="btn quiet small" type="button" onclick={() => closeSessions(i.id)}>{t(i.id === session.me?.id ? 'access.sessions.closeMine' : 'access.sessions.close', { n: i.sessions })}</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>
</li>