v3 : relecture de decbc0b — lien annulé retiré de la page, liens annulés avant les sessions, pluriel espagnol

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-23 09:26:56 +02:00
co-authored by Claude Opus 5.5
parent decbc0ba7b
commit dc2de0947c
10 changed files with 79 additions and 39 deletions
+5 -3
View File
@@ -14,8 +14,10 @@ coquilles des relectures 7 à 10, publication vers `yvv/decision-depot-textes-re
**Prêt, pas encore en ligne** (2026-10-23) : les sessions durent **jusqu'à ce qu'on les ferme** **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 (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. Raison : une sessions » par identité (appareil perdu, départ), jamais celle d'où l'on agit, avec le lien de
personne invitée n'a pas de clé Ğ1 pour revenir, une session expirée lui coûtait un nouveau lien. 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 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 `aya-woodpeckerci-local-agent-1` gardait l'ancienne socket (pipeline n° 31 : « Cannot connect to
@@ -23,7 +25,7 @@ the Docker daemon », rien construit). Remède : `docker restart aya-woodpeckerc
(fait avec l'accord d'Yvv), puis « Redémarrer » le pipeline dans l'interface Woodpecker. (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/`. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 70 tests dont 17 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) · 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 : 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 santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
+14 -14
View File
@@ -233,20 +233,7 @@ pub async fn close_sessions(
admin_only(&user)?; admin_only(&user)?;
auth::load_identity(&state, &id).await?; auth::load_identity(&state, &id).await?;
let current = auth::session_hash(&state, &headers).unwrap_or_default(); let current = auth::session_hash(&state, &headers).unwrap_or_default();
// Counted as the overview counts them; the expired ones go too. // The waiting link first: opened in between, it would open a session that stays.
let open: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sessions WHERE identity_id = ? AND token_hash != ? AND expires_at > ?",
)
.bind(&id)
.bind(&current)
.bind(util::now())
.fetch_one(&state.db)
.await?;
sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
.bind(&id)
.bind(&current)
.execute(&state.db)
.await?;
let links = sqlx::query( let links = sqlx::query(
"UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?", "UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?",
) )
@@ -256,6 +243,19 @@ pub async fn close_sessions(
.execute(&state.db) .execute(&state.db)
.await? .await?
.rows_affected(); .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"); tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed");
Ok(Json(json!({ "closed": open, "links": links }))) Ok(Json(json!({ "closed": open, "links": links })))
} }
+36 -11
View File
@@ -191,10 +191,18 @@ pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<Stri
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> Response { pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> Response {
let mut res = Json(json!({ "me": viewer })).into_response(); let mut res = Json(json!({ "me": viewer })).into_response();
if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) { if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) {
match renew(&state, &me.id, &token).await { match renew(&state, &token).await {
Ok(true) => { Ok(true) => {
res.headers_mut() res.headers_mut()
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400)); .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) => {} Ok(false) => {}
Err(e) => tracing::warn!(error = %e, "session not renewed"), Err(e) => tracing::warn!(error = %e, "session not renewed"),
@@ -204,7 +212,7 @@ pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer
} }
/// Whether the session was renewed: still open, and renewed more than a day ago. /// Whether the session was renewed: still open, and renewed more than a day ago.
async fn renew(state: &AppState, identity_id: &str, token: &str) -> AppResult<bool> { async fn renew(state: &AppState, token: &str) -> AppResult<bool> {
let renewed = let renewed =
sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?") sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?")
.bind(util::in_days(SESSION_DAYS)) .bind(util::in_days(SESSION_DAYS))
@@ -213,15 +221,7 @@ async fn renew(state: &AppState, identity_id: &str, token: &str) -> AppResult<bo
.bind(util::in_days(SESSION_DAYS - 1)) .bind(util::in_days(SESSION_DAYS - 1))
.execute(&state.db) .execute(&state.db)
.await?; .await?;
if renewed.rows_affected() != 1 { Ok(renewed.rows_affected() == 1)
return Ok(false);
}
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
.bind(util::now())
.bind(identity_id)
.execute(&state.db)
.await?;
Ok(true)
} }
/// Only local paths, never `//host` nor a scheme. /// Only local paths, never `//host` nor a scheme.
@@ -585,6 +585,31 @@ mod tests {
assert_eq!(safe_return(None), "/groupes"); 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] #[test]
fn cookie_parsing() { fn cookie_parsing() {
let mut h = HeaderMap::new(); let mut h = HeaderMap::new();
+4
View File
@@ -487,6 +487,10 @@ async fn a_session_lasts_until_it_is_closed() {
.execute(&state.db) .execute(&state.db)
.await .await
.expect("age the session"); .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; let (_, v) = chiara.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Chiara"); assert_eq!(v["me"]["displayName"], "Chiara");
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie); assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
+6 -6
View File
@@ -29,12 +29,12 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https (`__Host-`), jeton haché en base ; - 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 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 (ce qu'un navigateur garde au plus), recomptés à chaque visite, une fois par jour au plus. On la
ferme en se déconnectant, ou un administrateur ferme dans trustinWallet les sessions d'une ferme en se déconnectant. Un administrateur peut aussi, dans trustinWallet, fermer les sessions
identité (appareil perdu, départ), jamais celle d'où il agit, avec le lien de connexion qui d'une identité (appareil perdu, départ), sauf celle d'où il agit ; il annule du même geste le
l'attend encore. Une personne non administratrice qui perd un appareil le demande à un lien de connexion encore en attente pour cette identité. Une personne non administratrice qui
administrateur. (D'abord 30 jours fixes, changé le perd un appareil le demande à un administrateur. Jusqu'au 2026-10-23, les sessions duraient
2026-10-23 : une personne invitée n'a pas de clé Ğ1 pour revenir, et une session expirée lui 30 jours fixes ; on a changé parce qu'une personne invitée n'a pas de clé Ğ1 pour revenir, et
coûtait un nouveau lien.) 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 - 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), - **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
+2
View File
@@ -308,6 +308,8 @@ export const en: Messages = {
'access.sessions.closeMine': 'Close my other sessions ({n})', 'access.sessions.closeMine': 'Close my other sessions ({n})',
'access.sessions.closed.one': '{n} session closed.', 'access.sessions.closed.one': '{n} session closed.',
'access.sessions.closed.other': '{n} sessions 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.g1': 'Ğ1',
'access.kind.invite': 'invitation', 'access.kind.invite': 'invitation',
'access.kind.dev': 'dev', 'access.kind.dev': 'dev',
+2
View File
@@ -308,6 +308,8 @@ export const es: Messages = {
'access.sessions.closeMine': 'Cerrar mis otras sesiones ({n})', 'access.sessions.closeMine': 'Cerrar mis otras sesiones ({n})',
'access.sessions.closed.one': '{n} sesión cerrada.', 'access.sessions.closed.one': '{n} sesión cerrada.',
'access.sessions.closed.other': '{n} sesiones cerradas.', '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.g1': 'Ğ1',
'access.kind.invite': 'invitación', 'access.kind.invite': 'invitación',
'access.kind.dev': 'dev', 'access.kind.dev': 'dev',
+2
View File
@@ -311,6 +311,8 @@ export const fr = {
'access.sessions.closeMine': 'Fermer mes autres sessions ({n})', 'access.sessions.closeMine': 'Fermer mes autres sessions ({n})',
'access.sessions.closed.one': '{n} session fermée.', 'access.sessions.closed.one': '{n} session fermée.',
'access.sessions.closed.other': '{n} sessions fermées.', '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.g1': 'Ğ1',
'access.kind.invite': 'invitation', 'access.kind.invite': 'invitation',
'access.kind.dev': 'dev', '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}}`)); 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 => { 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 }); return this.t(`${key}.${one ? 'one' : 'other'}` as Key, { n, ...params });
}; };
+6 -3
View File
@@ -77,11 +77,14 @@
}); });
} }
// A lost device, a person who left: their sessions close (never the one in use here). // 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) { async function closeSessions(id: string) {
await act(async () => { await act(async () => {
const r = await api.del<{ closed: number }>(`/access/identities/${encodeURIComponent(id)}/sessions`); const r = await api.del<{ closed: number; links: number }>(`/access/identities/${encodeURIComponent(id)}/sessions`);
toasts.show(i18n.plural('access.sessions.closed', r.closed)); 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);
}); });
} }