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**
(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
personne invitée n'a pas de clé Ğ1 pour revenir, une session expirée lui coûtait un nouveau lien.
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
@@ -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.
- 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) ·
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
+14 -14
View File
@@ -233,20 +233,7 @@ pub async fn close_sessions(
admin_only(&user)?;
auth::load_identity(&state, &id).await?;
let current = auth::session_hash(&state, &headers).unwrap_or_default();
// Counted as the overview counts them; the expired ones go too.
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?;
// 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 > ?",
)
@@ -256,6 +243,19 @@ pub async fn close_sessions(
.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 })))
}
+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 {
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, &me.id, &token).await {
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"),
@@ -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.
async fn renew(state: &AppState, identity_id: &str, token: &str) -> AppResult<bool> {
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))
@@ -213,15 +221,7 @@ async fn renew(state: &AppState, identity_id: &str, token: &str) -> AppResult<bo
.bind(util::in_days(SESSION_DAYS - 1))
.execute(&state.db)
.await?;
if 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)
Ok(renewed.rows_affected() == 1)
}
/// Only local paths, never `//host` nor a scheme.
@@ -585,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();
+4
View File
@@ -487,6 +487,10 @@ async fn a_session_lasts_until_it_is_closed() {
.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);
+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 ;
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, ou un administrateur ferme dans trustinWallet les sessions d'une
identité (appareil perdu, départ), jamais celle d'où il agit, avec le lien de connexion qui
l'attend encore. Une personne non administratrice qui perd un appareil le demande à un
administrateur. (D'abord 30 jours fixes, changé le
2026-10-23 : une personne invitée n'a pas de clé Ğ1 pour revenir, et une session expirée lui
coûtait un nouveau lien.)
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),
+2
View File
@@ -308,6 +308,8 @@ export const en: Messages = {
'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',
+2
View File
@@ -308,6 +308,8 @@ export const es: Messages = {
'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',
+2
View File
@@ -311,6 +311,8 @@ export const fr = {
'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 });
};
+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) {
await act(async () => {
const r = await api.del<{ closed: number }>(`/access/identities/${encodeURIComponent(id)}/sessions`);
toasts.show(i18n.plural('access.sessions.closed', r.closed));
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);
});
}