diff --git a/HANDOFF.md b/HANDOFF.md index 60e01c9..bb08b0c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs index d620b52..7732af3 100644 --- a/crates/ld-server/src/access.rs +++ b/crates/ld-server/src/access.rs @@ -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(¤t) - .bind(util::now()) - .fetch_one(&state.db) - .await?; - sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?") - .bind(&id) - .bind(¤t) - .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(¤t) + .bind(util::now()) + .execute(&state.db) + .await? + .rows_affected(); + sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?") + .bind(&id) + .bind(¤t) + .execute(&state.db) + .await?; tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed"); Ok(Json(json!({ "closed": open, "links": links }))) } diff --git a/crates/ld-server/src/auth.rs b/crates/ld-server/src/auth.rs index 7a5e76f..e226564 100644 --- a/crates/ld-server/src/auth.rs +++ b/crates/ld-server/src/auth.rs @@ -191,10 +191,18 @@ pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option, 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, 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 { +async fn renew(state: &AppState, token: &str) -> AppResult { 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 (k in params ? String(params[k]) : `{${k}}`)); }; - /** `.one` / `.other` — French and Spanish use « one » for 0 and 1. */ + /** `.one` / `.other` — French says « 0 session », English and Spanish « 0 sessions ». */ plural = (key: string, n: number, params: Record = {}): 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 }); }; diff --git a/web/src/routes/acces/+page.svelte b/web/src/routes/acces/+page.svelte index 660007a..9416fba 100644 --- a/web/src/routes/acces/+page.svelte +++ b/web/src/routes/acces/+page.svelte @@ -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); }); }