judu/ex-egopublic Fork
⑂ b0ed1ae
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(changelog): annonces de nouveautés — interstitiel /annonces, gate des racines nues, migration 0016Unverified

Julien Durillon committed 2026-08-26T17:23:31+02:00 Browse files
b0ed1ae parent: 6cfca47
modified a11y/audit.mjs +29 -5
@@ -1,6 +1,6 @@
11 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
22 //
3-// Parcourt les 15 écrans de l'app (viewport mobile 390×844) et échoue
3+// Parcourt les 16 écrans de l'app (viewport mobile 390×844) et échoue
44 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
55 // les violations moindres sont listées en avertissement.
66 //
@@ -13,6 +13,15 @@
1313 // cargo run -- user join audit --team "Audit 2"
1414 // (saisir un temps via l'UI, ou en SQL)
1515 //
16+// Pour auditer l'écran /annonces, poser une annonce non lue datée
17+// d'aujourd'hui (le compte d'audit doit être plus ancien qu'elle) :
18+//
19+// INSERT INTO changelog_announcements (id, date, version, text_fr, text_en, text_de, text_ptbr)
20+// VALUES (gen_random_uuid(), CURRENT_DATE, 'audit', 'Texte.', 'Text.', 'Text.', 'Texto.');
21+//
22+// L'audit la ferme lui-même (OK = marquer lu) juste après l'avoir passée,
23+// pour libérer les autres écrans de la redirection.
24+//
1625 // Usage :
1726 // cd a11y && npm install
1827 // TT_USER=audit TT_PASS=xxxxxxxx npm run audit
@@ -57,15 +66,20 @@ if (page.url().includes('/login')) {
5766 process.exit(2);
5867 }
5968
60-// Une catégorie, une saisie et une todo existantes servent aux pages paramétrées.
69+// Une catégorie, une saisie et une todo existantes servent aux pages
70+// paramétrées. Les URLs portent une query : une racine NUE serait redirigée
71+// vers /annonces s'il reste une annonce non lue (c'est aussi comme ça qu'on
72+// détecte l'écran annonces : l'URL finale du fetch dit s'il existe).
6173 const ids = await page.evaluate(async () => {
6274 const step1 = await fetch('/temps/nouveau?mode=manuel').then((r) => r.text());
63- const times = await fetch('/temps').then((r) => r.text());
64- const todos = await fetch('/taches').then((r) => r.text());
75+ const times = await fetch('/temps?page=1').then((r) => r.text());
76+ const todos = await fetch('/taches?encours=tous').then((r) => r.text());
77+ const annonces = await fetch('/annonces');
6578 return {
6679 categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null,
6780 entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,
6881 todo: todos.match(/\/taches\/([0-9a-f-]{36})\/terminer/)?.[1] ?? null,
82+ annonces: annonces.url.includes('/annonces'),
6983 };
7084 });
7185
@@ -74,6 +88,15 @@ const pages = [
7488 // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec
7589 // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription.
7690 { name: 'signup', url: '/inscription', anon: true },
91+ // AVANT les autres écrans connectés : tant qu'elle n'est pas lue, l'annonce
92+ // les redirigerait tous ici. Le hook `then` la ferme après l'audit.
93+ ids.annonces && {
94+ name: 'annonces',
95+ url: '/annonces',
96+ then: async (pg) => {
97+ await Promise.all([pg.waitForNavigation({ waitUntil: 'networkidle0' }), pg.click('.announce-ok')]);
98+ },
99+ },
77100 { name: 'add-choice', url: '/temps/nouveau' },
78101 { name: 'add-step1', url: '/temps/nouveau?mode=manuel' },
79102 ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` },
@@ -90,7 +113,7 @@ const pages = [
90113 { name: 'todo-new', url: '/taches/nouvelle' },
91114 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
92115 ].filter(Boolean);
93-if (pages.length < 15) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');
116+if (pages.length < 16) console.warn('⚠ catégorie, saisie, todo ou annonce manquante : certains écrans sont sautés.');
94117
95118 let blocking = 0;
96119 for (const p of pages) {
@@ -117,6 +140,7 @@ for (const p of pages) {
117140 console.log(`${hard ? '✘' : '⚠'} ${p.name}: [${v.impact}] ${v.id} ×${v.count} — ${v.help}`);
118141 for (const t of v.targets) console.log(` · ${t}`);
119142 }
143+ if (p.then) await p.then(pg);
120144 if (ctx) await ctx.close();
121145 }
122146
@@ -1,6 +1,6 @@
1 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.1 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
2 //2 //
3-// Parcourt les 15 écrans de l'app (viewport mobile 390×844) et échoue3+// Parcourt les 16 écrans de l'app (viewport mobile 390×844) et échoue
4 // (exit 1) à la moindre violation axe « critical » ou « serious » ;4 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
5 // les violations moindres sont listées en avertissement.5 // les violations moindres sont listées en avertissement.
6 //6 //
@@ -13,6 +13,15 @@
13 // cargo run -- user join audit --team "Audit 2"13 // cargo run -- user join audit --team "Audit 2"
14 // (saisir un temps via l'UI, ou en SQL)14 // (saisir un temps via l'UI, ou en SQL)
15 //15 //
16+// Pour auditer l'écran /annonces, poser une annonce non lue datée
17+// d'aujourd'hui (le compte d'audit doit être plus ancien qu'elle) :
18+//
19+// INSERT INTO changelog_announcements (id, date, version, text_fr, text_en, text_de, text_ptbr)
20+// VALUES (gen_random_uuid(), CURRENT_DATE, 'audit', 'Texte.', 'Text.', 'Text.', 'Texto.');
21+//
22+// L'audit la ferme lui-même (OK = marquer lu) juste après l'avoir passée,
23+// pour libérer les autres écrans de la redirection.
24+//
16 // Usage :25 // Usage :
17 // cd a11y && npm install26 // cd a11y && npm install
18 // TT_USER=audit TT_PASS=xxxxxxxx npm run audit27 // TT_USER=audit TT_PASS=xxxxxxxx npm run audit
@@ -57,15 +66,20 @@ if (page.url().includes('/login')) {
57 process.exit(2);66 process.exit(2);
58 }67 }
59 68
60-// Une catégorie, une saisie et une todo existantes servent aux pages paramétrées.69+// Une catégorie, une saisie et une todo existantes servent aux pages
70+// paramétrées. Les URLs portent une query : une racine NUE serait redirigée
71+// vers /annonces s'il reste une annonce non lue (c'est aussi comme ça qu'on
72+// détecte l'écran annonces : l'URL finale du fetch dit s'il existe).
61 const ids = await page.evaluate(async () => {73 const ids = await page.evaluate(async () => {
62 const step1 = await fetch('/temps/nouveau?mode=manuel').then((r) => r.text());74 const step1 = await fetch('/temps/nouveau?mode=manuel').then((r) => r.text());
63- const times = await fetch('/temps').then((r) => r.text());75+ const times = await fetch('/temps?page=1').then((r) => r.text());
64- const todos = await fetch('/taches').then((r) => r.text());76+ const todos = await fetch('/taches?encours=tous').then((r) => r.text());
77+ const annonces = await fetch('/annonces');
65 return {78 return {
66 categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null,79 categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null,
67 entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,80 entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,
68 todo: todos.match(/\/taches\/([0-9a-f-]{36})\/terminer/)?.[1] ?? null,81 todo: todos.match(/\/taches\/([0-9a-f-]{36})\/terminer/)?.[1] ?? null,
82+ annonces: annonces.url.includes('/annonces'),
69 };83 };
70 });84 });
71 85
@@ -74,6 +88,15 @@ const pages = [
74 // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec88 // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec
75 // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription.89 // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription.
76 { name: 'signup', url: '/inscription', anon: true },90 { name: 'signup', url: '/inscription', anon: true },
91+ // AVANT les autres écrans connectés : tant qu'elle n'est pas lue, l'annonce
92+ // les redirigerait tous ici. Le hook `then` la ferme après l'audit.
93+ ids.annonces && {
94+ name: 'annonces',
95+ url: '/annonces',
96+ then: async (pg) => {
97+ await Promise.all([pg.waitForNavigation({ waitUntil: 'networkidle0' }), pg.click('.announce-ok')]);
98+ },
99+ },
77 { name: 'add-choice', url: '/temps/nouveau' },100 { name: 'add-choice', url: '/temps/nouveau' },
78 { name: 'add-step1', url: '/temps/nouveau?mode=manuel' },101 { name: 'add-step1', url: '/temps/nouveau?mode=manuel' },
79 ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` },102 ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` },
@@ -90,7 +113,7 @@ const pages = [
90 { name: 'todo-new', url: '/taches/nouvelle' },113 { name: 'todo-new', url: '/taches/nouvelle' },
91 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },114 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
92 ].filter(Boolean);115 ].filter(Boolean);
93-if (pages.length < 15) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');116+if (pages.length < 16) console.warn('⚠ catégorie, saisie, todo ou annonce manquante : certains écrans sont sautés.');
94 117
95 let blocking = 0;118 let blocking = 0;
96 for (const p of pages) {119 for (const p of pages) {
@@ -117,6 +140,7 @@ for (const p of pages) {
117 console.log(`${hard ? '✘' : '⚠'} ${p.name}: [${v.impact}] ${v.id} ×${v.count} — ${v.help}`);140 console.log(`${hard ? '✘' : '⚠'} ${p.name}: [${v.impact}] ${v.id} ×${v.count} — ${v.help}`);
118 for (const t of v.targets) console.log(` · ${t}`);141 for (const t of v.targets) console.log(` · ${t}`);
119 }142 }
143+ if (p.then) await p.then(pg);
120 if (ctx) await ctx.close();144 if (ctx) await ctx.close();
121 }145 }
122 146
modified assets/style.css +22 -0
@@ -424,6 +424,28 @@ a.team-pill:hover { filter: brightness(.96); }
424424 .signup h1 { font-size: 34px; }
425425 .signup .tagline { font-size: 16px; margin-bottom: var(--space-6); }
426426
427+/* — annonces (changelog) : page interstitielle hors shell —
428+ Variante de .login (même squelette épuré), mais le contenu peut être long :
429+ ancré en haut et défilant, carte centrée à largeur bornée sur grand écran.
430+ En HC les tokens --shadow-* sont déjà des liserés : rien à surcharger. */
431+.announce { justify-content: flex-start; }
432+.announce form {
433+ width: 100%; max-width: 560px; margin: 0 auto;
434+ display: flex; flex-direction: column;
435+}
436+.announce-head {
437+ display: flex; align-items: baseline; gap: var(--space-3);
438+ margin: 0 0 var(--space-4);
439+}
440+.announce-head h1 { font-size: 34px; margin: 0; text-align: left; }
441+.announce-head .btn-ghost { margin-left: auto; }
442+.announce-list { display: grid; gap: var(--space-3); margin-bottom: var(--space-6); }
443+.announce-card { padding: var(--space-4); box-shadow: var(--shadow-md); }
444+.announce-card h2 { margin: 0; font-size: 21px; letter-spacing: -0.01em; }
445+.announce-date { margin: 0 0 var(--space-1); font-size: 13px; color: var(--color-accent-700); }
446+.announce-text { margin: 0; font-size: 15px; line-height: 1.5; }
447+.announce-ok { min-height: 48px; font-size: 16px; }
448+
427449 /* — ajout étape 1 : liste de catégories — */
428450 .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); }
429451 .category-pick { display: grid; gap: var(--space-2); }
@@ -424,6 +424,28 @@ a.team-pill:hover { filter: brightness(.96); }
424 .signup h1 { font-size: 34px; }424 .signup h1 { font-size: 34px; }
425 .signup .tagline { font-size: 16px; margin-bottom: var(--space-6); }425 .signup .tagline { font-size: 16px; margin-bottom: var(--space-6); }
426 426
427+/* — annonces (changelog) : page interstitielle hors shell —
428+ Variante de .login (même squelette épuré), mais le contenu peut être long :
429+ ancré en haut et défilant, carte centrée à largeur bornée sur grand écran.
430+ En HC les tokens --shadow-* sont déjà des liserés : rien à surcharger. */
431+.announce { justify-content: flex-start; }
432+.announce form {
433+ width: 100%; max-width: 560px; margin: 0 auto;
434+ display: flex; flex-direction: column;
435+}
436+.announce-head {
437+ display: flex; align-items: baseline; gap: var(--space-3);
438+ margin: 0 0 var(--space-4);
439+}
440+.announce-head h1 { font-size: 34px; margin: 0; text-align: left; }
441+.announce-head .btn-ghost { margin-left: auto; }
442+.announce-list { display: grid; gap: var(--space-3); margin-bottom: var(--space-6); }
443+.announce-card { padding: var(--space-4); box-shadow: var(--shadow-md); }
444+.announce-card h2 { margin: 0; font-size: 21px; letter-spacing: -0.01em; }
445+.announce-date { margin: 0 0 var(--space-1); font-size: 13px; color: var(--color-accent-700); }
446+.announce-text { margin: 0; font-size: 15px; line-height: 1.5; }
447+.announce-ok { min-height: 48px; font-size: 16px; }
448+
427 /* — ajout étape 1 : liste de catégories — */449 /* — ajout étape 1 : liste de catégories — */
428 .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); }450 .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); }
429 .category-pick { display: grid; gap: var(--space-2); }451 .category-pick { display: grid; gap: var(--space-2); }
added migrations/0016_changelog.sql +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+-- Annonces de changelog : des messages de nouveautés affichés dans l'app.
2+--
3+-- Il n'y a pas de rôle admin, donc pas d'UI d'écriture : les annonces sont
4+-- créées par migration SQL (elles partent avec le déploiement qui les
5+-- annonce, appliquées au boot comme le reste) ; les retouches d'urgence en
6+-- psql sont tolérées, la table n'est pas du schéma critique.
7+--
8+-- Les quatre textes sont des colonnes NOT NULL : même garantie d'exhaustivité
9+-- que le struct i18n::Tr côté Rust — une annonce sans ses quatre langues ne
10+-- passe pas. Pas de table enum ici : rien n'est un domaine fermé (la langue
11+-- n'est pas une VALEUR de ces lignes, c'est leur STRUCTURE).
12+--
13+-- Visibilité : un utilisateur voit une annonce si sa date d'inscription
14+-- (users.created_at, ramenée en jours Europe/Paris) est STRICTEMENT
15+-- antérieure à la date éditoriale de l'annonce — un nouvel inscrit découvre
16+-- l'app entière, il n'a rien à rattraper. Fermer la page = marquer lu
17+-- (une ligne dans changelog_reads) ; une annonce lue ne réapparaît jamais,
18+-- il n'y a pas d'historique consultable.
19+
20+CREATE TABLE changelog_announcements (
21+ -- UUIDv7 généré côté application (Rust ou à la main dans la migration
22+ -- d'annonce), jamais en base — ordre lexicographique ≈ ordre de création.
23+ id UUID PRIMARY KEY,
24+ -- Date éditoriale : porte la règle de visibilité et l'affichage.
25+ date DATE NOT NULL,
26+ -- Texte libre affiché dans le titre (« Nouveautés de la v0.3.0 ») —
27+ -- aucune cohérence imposée avec CARGO_PKG_VERSION.
28+ version TEXT NOT NULL,
29+ -- Une ligne de texte = un paragraphe à l'affichage.
30+ text_fr TEXT NOT NULL,
31+ text_en TEXT NOT NULL,
32+ text_de TEXT NOT NULL,
33+ text_ptbr TEXT NOT NULL,
34+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
35+);
36+
37+CREATE TABLE changelog_reads (
38+ changelog_id UUID NOT NULL REFERENCES changelog_announcements (id) ON DELETE CASCADE,
39+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
40+ read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41+ PRIMARY KEY (changelog_id, user_id)
42+);
new file mode 100644
@@ -0,0 +1,42 @@
1+-- Annonces de changelog : des messages de nouveautés affichés dans l'app.
2+--
3+-- Il n'y a pas de rôle admin, donc pas d'UI d'écriture : les annonces sont
4+-- créées par migration SQL (elles partent avec le déploiement qui les
5+-- annonce, appliquées au boot comme le reste) ; les retouches d'urgence en
6+-- psql sont tolérées, la table n'est pas du schéma critique.
7+--
8+-- Les quatre textes sont des colonnes NOT NULL : même garantie d'exhaustivité
9+-- que le struct i18n::Tr côté Rust — une annonce sans ses quatre langues ne
10+-- passe pas. Pas de table enum ici : rien n'est un domaine fermé (la langue
11+-- n'est pas une VALEUR de ces lignes, c'est leur STRUCTURE).
12+--
13+-- Visibilité : un utilisateur voit une annonce si sa date d'inscription
14+-- (users.created_at, ramenée en jours Europe/Paris) est STRICTEMENT
15+-- antérieure à la date éditoriale de l'annonce — un nouvel inscrit découvre
16+-- l'app entière, il n'a rien à rattraper. Fermer la page = marquer lu
17+-- (une ligne dans changelog_reads) ; une annonce lue ne réapparaît jamais,
18+-- il n'y a pas d'historique consultable.
19+
20+CREATE TABLE changelog_announcements (
21+ -- UUIDv7 généré côté application (Rust ou à la main dans la migration
22+ -- d'annonce), jamais en base — ordre lexicographique ≈ ordre de création.
23+ id UUID PRIMARY KEY,
24+ -- Date éditoriale : porte la règle de visibilité et l'affichage.
25+ date DATE NOT NULL,
26+ -- Texte libre affiché dans le titre (« Nouveautés de la v0.3.0 ») —
27+ -- aucune cohérence imposée avec CARGO_PKG_VERSION.
28+ version TEXT NOT NULL,
29+ -- Une ligne de texte = un paragraphe à l'affichage.
30+ text_fr TEXT NOT NULL,
31+ text_en TEXT NOT NULL,
32+ text_de TEXT NOT NULL,
33+ text_ptbr TEXT NOT NULL,
34+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
35+);
36+
37+CREATE TABLE changelog_reads (
38+ changelog_id UUID NOT NULL REFERENCES changelog_announcements (id) ON DELETE CASCADE,
39+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
40+ read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41+ PRIMARY KEY (changelog_id, user_id)
42+);
added src/db/changelog.rs +67 -0
new file mode 100644
@@ -0,0 +1,67 @@
1+use chrono::NaiveDate;
2+use sqlx::PgPool;
3+use uuid::Uuid;
4+
5+/// Annonce de changelog non lue (page interstitielle /annonces).
6+/// Les quatre textes sont rapportés tels quels : le choix de la langue se
7+/// fait en Rust (comme partout — le SQL ne connaît pas la langue de l'UI).
8+#[derive(sqlx::FromRow)]
9+pub struct Announcement {
10+ pub id: Uuid,
11+ pub date: NaiveDate,
12+ pub version: String,
13+ pub text_fr: String,
14+ pub text_en: String,
15+ pub text_de: String,
16+ pub text_ptbr: String,
17+}
18+
19+/// La condition de visibilité, partagée par has_unread et unread : annonce
20+/// STRICTEMENT postérieure (en jours Europe/Paris) à l'inscription, et pas
21+/// encore marquée lue.
22+const UNREAD_WHERE: &str = "a.date > (SELECT (created_at AT TIME ZONE 'Europe/Paris')::date
23+ FROM users WHERE id = $1)
24+ AND NOT EXISTS (SELECT 1 FROM changelog_reads r
25+ WHERE r.changelog_id = a.id AND r.user_id = $1)";
26+
27+/// Le test du gate : tourne sur chaque GET de racine d'onglet nue,
28+/// d'où le simple EXISTS.
29+pub async fn has_unread(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> {
30+ let (found,): (bool,) = sqlx::query_as(&format!(
31+ "SELECT EXISTS (SELECT 1 FROM changelog_announcements a WHERE {UNREAD_WHERE})"
32+ ))
33+ .bind(user_id)
34+ .fetch_one(pool)
35+ .await?;
36+ Ok(found)
37+}
38+
39+/// Les annonces non lues, plus récente en haut.
40+pub async fn unread(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Announcement>> {
41+ sqlx::query_as(&format!(
42+ "SELECT a.id, a.date, a.version, a.text_fr, a.text_en, a.text_de, a.text_ptbr
43+ FROM changelog_announcements a
44+ WHERE {UNREAD_WHERE}
45+ ORDER BY a.date DESC, a.id DESC"
46+ ))
47+ .bind(user_id)
48+ .fetch_all(pool)
49+ .await
50+}
51+
52+/// Fermer = marquer lu. Idempotent (double soumission, onglets multiples) et
53+/// robuste aux ids inconnus : on n'insère que ce qui existe vraiment, un id
54+/// forgé est simplement ignoré (il ne marquerait que la propre vue du user
55+/// de toute façon).
56+pub async fn mark_read(pool: &PgPool, user_id: Uuid, ids: &[Uuid]) -> sqlx::Result<u64> {
57+ let res = sqlx::query(
58+ "INSERT INTO changelog_reads (changelog_id, user_id)
59+ SELECT id, $2 FROM changelog_announcements WHERE id = ANY($1)
60+ ON CONFLICT DO NOTHING",
61+ )
62+ .bind(ids)
63+ .bind(user_id)
64+ .execute(pool)
65+ .await?;
66+ Ok(res.rows_affected())
67+}
new file mode 100644
@@ -0,0 +1,67 @@
1+use chrono::NaiveDate;
2+use sqlx::PgPool;
3+use uuid::Uuid;
4+
5+/// Annonce de changelog non lue (page interstitielle /annonces).
6+/// Les quatre textes sont rapportés tels quels : le choix de la langue se
7+/// fait en Rust (comme partout — le SQL ne connaît pas la langue de l'UI).
8+#[derive(sqlx::FromRow)]
9+pub struct Announcement {
10+ pub id: Uuid,
11+ pub date: NaiveDate,
12+ pub version: String,
13+ pub text_fr: String,
14+ pub text_en: String,
15+ pub text_de: String,
16+ pub text_ptbr: String,
17+}
18+
19+/// La condition de visibilité, partagée par has_unread et unread : annonce
20+/// STRICTEMENT postérieure (en jours Europe/Paris) à l'inscription, et pas
21+/// encore marquée lue.
22+const UNREAD_WHERE: &str = "a.date > (SELECT (created_at AT TIME ZONE 'Europe/Paris')::date
23+ FROM users WHERE id = $1)
24+ AND NOT EXISTS (SELECT 1 FROM changelog_reads r
25+ WHERE r.changelog_id = a.id AND r.user_id = $1)";
26+
27+/// Le test du gate : tourne sur chaque GET de racine d'onglet nue,
28+/// d'où le simple EXISTS.
29+pub async fn has_unread(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> {
30+ let (found,): (bool,) = sqlx::query_as(&format!(
31+ "SELECT EXISTS (SELECT 1 FROM changelog_announcements a WHERE {UNREAD_WHERE})"
32+ ))
33+ .bind(user_id)
34+ .fetch_one(pool)
35+ .await?;
36+ Ok(found)
37+}
38+
39+/// Les annonces non lues, plus récente en haut.
40+pub async fn unread(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Announcement>> {
41+ sqlx::query_as(&format!(
42+ "SELECT a.id, a.date, a.version, a.text_fr, a.text_en, a.text_de, a.text_ptbr
43+ FROM changelog_announcements a
44+ WHERE {UNREAD_WHERE}
45+ ORDER BY a.date DESC, a.id DESC"
46+ ))
47+ .bind(user_id)
48+ .fetch_all(pool)
49+ .await
50+}
51+
52+/// Fermer = marquer lu. Idempotent (double soumission, onglets multiples) et
53+/// robuste aux ids inconnus : on n'insère que ce qui existe vraiment, un id
54+/// forgé est simplement ignoré (il ne marquerait que la propre vue du user
55+/// de toute façon).
56+pub async fn mark_read(pool: &PgPool, user_id: Uuid, ids: &[Uuid]) -> sqlx::Result<u64> {
57+ let res = sqlx::query(
58+ "INSERT INTO changelog_reads (changelog_id, user_id)
59+ SELECT id, $2 FROM changelog_announcements WHERE id = ANY($1)
60+ ON CONFLICT DO NOTHING",
61+ )
62+ .bind(ids)
63+ .bind(user_id)
64+ .execute(pool)
65+ .await?;
66+ Ok(res.rows_affected())
67+}
modified src/db/mod.rs +1 -0
@@ -1,3 +1,4 @@
1+pub mod changelog;
12 pub mod entries;
23 pub mod passkeys;
34 pub mod invitations;
@@ -1,3 +1,4 @@
1+pub mod changelog;
1 pub mod entries;2 pub mod entries;
2 pub mod passkeys;3 pub mod passkeys;
3 pub mod invitations;4 pub mod invitations;
modified src/i18n.rs +23 -0
@@ -89,6 +89,13 @@ pub struct Tr {
8989 /// Pastille d'action du dock du minuteur.
9090 pub stop_cta: &'static str,
9191 pub record_cta: &'static str,
92+ // ─── annonces (changelog) ───
93+ /// Titre de la page interstitielle /annonces.
94+ pub title_changelog: &'static str,
95+ /// « Nouveautés de la {} » — {} = le champ version (texte libre).
96+ pub changelog_card_title: &'static str,
97+ pub changelog_close: &'static str,
98+ pub changelog_ok: &'static str,
9299 // ─── login ───
93100 pub login_title: &'static str,
94101 pub tagline: &'static str,
@@ -449,6 +456,10 @@ pub static FR: Tr = Tr {
449456 timer_in_team_pat: "minuteur en cours dans l'équipe {}",
450457 stop_cta: "Arrêter",
451458 record_cta: "Enregistrer",
459+ title_changelog: "Nouveautés",
460+ changelog_card_title: "Nouveautés de la {}",
461+ changelog_close: "Fermer",
462+ changelog_ok: "OK",
452463 login_title: "Connexion",
453464 tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.",
454465 username_label: "Identifiant",
@@ -748,6 +759,10 @@ pub static EN: Tr = Tr {
748759 timer_in_team_pat: "timer running in team {}",
749760 stop_cta: "Stop",
750761 record_cta: "Record",
762+ title_changelog: "What's new",
763+ changelog_card_title: "What's new in {}",
764+ changelog_close: "Close",
765+ changelog_ok: "OK",
751766 login_title: "Sign in",
752767 tagline: "Time spent on each category, shared with your team.",
753768 username_label: "Username",
@@ -1047,6 +1062,10 @@ pub static DE: Tr = Tr {
10471062 timer_in_team_pat: "Timer läuft im Team {}",
10481063 stop_cta: "Stoppen",
10491064 record_cta: "Erfassen",
1065+ title_changelog: "Neuigkeiten",
1066+ changelog_card_title: "Neu in {}",
1067+ changelog_close: "Schließen",
1068+ changelog_ok: "OK",
10501069 login_title: "Anmeldung",
10511070 tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.",
10521071 username_label: "Benutzername",
@@ -1346,6 +1365,10 @@ pub static PT_BR: Tr = Tr {
13461365 timer_in_team_pat: "cronômetro em andamento na equipe {}",
13471366 stop_cta: "Parar",
13481367 record_cta: "Registrar",
1368+ title_changelog: "Novidades",
1369+ changelog_card_title: "Novidades da {}",
1370+ changelog_close: "Fechar",
1371+ changelog_ok: "OK",
13491372 login_title: "Entrar",
13501373 tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.",
13511374 username_label: "Usuário",
@@ -89,6 +89,13 @@ pub struct Tr {
89 /// Pastille d'action du dock du minuteur.89 /// Pastille d'action du dock du minuteur.
90 pub stop_cta: &'static str,90 pub stop_cta: &'static str,
91 pub record_cta: &'static str,91 pub record_cta: &'static str,
92+ // ─── annonces (changelog) ───
93+ /// Titre de la page interstitielle /annonces.
94+ pub title_changelog: &'static str,
95+ /// « Nouveautés de la {} » — {} = le champ version (texte libre).
96+ pub changelog_card_title: &'static str,
97+ pub changelog_close: &'static str,
98+ pub changelog_ok: &'static str,
92 // ─── login ───99 // ─── login ───
93 pub login_title: &'static str,100 pub login_title: &'static str,
94 pub tagline: &'static str,101 pub tagline: &'static str,
@@ -449,6 +456,10 @@ pub static FR: Tr = Tr {
449 timer_in_team_pat: "minuteur en cours dans l'équipe {}",456 timer_in_team_pat: "minuteur en cours dans l'équipe {}",
450 stop_cta: "Arrêter",457 stop_cta: "Arrêter",
451 record_cta: "Enregistrer",458 record_cta: "Enregistrer",
459+ title_changelog: "Nouveautés",
460+ changelog_card_title: "Nouveautés de la {}",
461+ changelog_close: "Fermer",
462+ changelog_ok: "OK",
452 login_title: "Connexion",463 login_title: "Connexion",
453 tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.",464 tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.",
454 username_label: "Identifiant",465 username_label: "Identifiant",
@@ -748,6 +759,10 @@ pub static EN: Tr = Tr {
748 timer_in_team_pat: "timer running in team {}",759 timer_in_team_pat: "timer running in team {}",
749 stop_cta: "Stop",760 stop_cta: "Stop",
750 record_cta: "Record",761 record_cta: "Record",
762+ title_changelog: "What's new",
763+ changelog_card_title: "What's new in {}",
764+ changelog_close: "Close",
765+ changelog_ok: "OK",
751 login_title: "Sign in",766 login_title: "Sign in",
752 tagline: "Time spent on each category, shared with your team.",767 tagline: "Time spent on each category, shared with your team.",
753 username_label: "Username",768 username_label: "Username",
@@ -1047,6 +1062,10 @@ pub static DE: Tr = Tr {
1047 timer_in_team_pat: "Timer läuft im Team {}",1062 timer_in_team_pat: "Timer läuft im Team {}",
1048 stop_cta: "Stoppen",1063 stop_cta: "Stoppen",
1049 record_cta: "Erfassen",1064 record_cta: "Erfassen",
1065+ title_changelog: "Neuigkeiten",
1066+ changelog_card_title: "Neu in {}",
1067+ changelog_close: "Schließen",
1068+ changelog_ok: "OK",
1050 login_title: "Anmeldung",1069 login_title: "Anmeldung",
1051 tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.",1070 tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.",
1052 username_label: "Benutzername",1071 username_label: "Benutzername",
@@ -1346,6 +1365,10 @@ pub static PT_BR: Tr = Tr {
1346 timer_in_team_pat: "cronômetro em andamento na equipe {}",1365 timer_in_team_pat: "cronômetro em andamento na equipe {}",
1347 stop_cta: "Parar",1366 stop_cta: "Parar",
1348 record_cta: "Registrar",1367 record_cta: "Registrar",
1368+ title_changelog: "Novidades",
1369+ changelog_card_title: "Novidades da {}",
1370+ changelog_close: "Fechar",
1371+ changelog_ok: "OK",
1349 login_title: "Entrar",1372 login_title: "Entrar",
1350 tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.",1373 tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.",
1351 username_label: "Usuário",1374 username_label: "Usuário",
added src/routes/changelog.rs +172 -0
new file mode 100644
@@ -0,0 +1,172 @@
1+use askama::Template;
2+use axum::Form;
3+use axum::extract::{FromRequestParts, Query, State};
4+use axum::http::request::Parts;
5+use axum::response::{IntoResponse, Redirect, Response};
6+use serde::Deserialize;
7+use uuid::Uuid;
8+
9+use crate::db::changelog;
10+use crate::error::AppError;
11+use crate::i18n::{Lang, p1};
12+use crate::render::HtmlTemplate;
13+use crate::state::AppState;
14+use crate::team::ActiveTeam;
15+
16+/// Garde des cinq racines d'onglet : un GET « nu » (aucune query string) est
17+/// redirigé vers /annonces s'il reste des annonces non lues. Une query
18+/// signale un flow en cours (?categorie=, ?ok= des toasts, ?page=,
19+/// ?semaine=…) qu'on n'interrompt jamais — c'est la navigation entre vues
20+/// qui déclenche, pas les étapes d'un parcours.
21+///
22+/// Les requêtes non authentifiées passent sans bruit : c'est AuthUser /
23+/// ActiveTeam, plus loin dans le handler, qui produira le redirect /login.
24+pub struct ChangelogGate;
25+
26+pub enum GateRejection {
27+ /// Des annonces attendent — porte le chemin demandé, à retrouver après
28+ /// fermeture.
29+ Announce(String),
30+ App(AppError),
31+}
32+
33+impl IntoResponse for GateRejection {
34+ fn into_response(self) -> Response {
35+ match self {
36+ GateRejection::Announce(path) => {
37+ Redirect::to(&format!("/annonces?retour={}", super::urlenc(&path)))
38+ .into_response()
39+ }
40+ GateRejection::App(e) => e.into_response(),
41+ }
42+ }
43+}
44+
45+impl FromRequestParts<AppState> for ChangelogGate {
46+ type Rejection = GateRejection;
47+
48+ async fn from_request_parts(
49+ parts: &mut Parts,
50+ state: &AppState,
51+ ) -> Result<Self, GateRejection> {
52+ if parts.uri.query().is_some() {
53+ return Ok(ChangelogGate);
54+ }
55+ let Some(user) = crate::auth::extractor::authenticate(parts, state) else {
56+ return Ok(ChangelogGate);
57+ };
58+ match changelog::has_unread(&state.pool, user.id).await {
59+ Ok(true) => Err(GateRejection::Announce(parts.uri.path().to_string())),
60+ Ok(false) => Ok(ChangelogGate),
61+ Err(e) => Err(GateRejection::App(AppError::Db(e))),
62+ }
63+ }
64+}
65+
66+/// Une annonce prête à afficher : langue choisie, paragraphes découpés
67+/// (une ligne du texte en base = un <p> — jamais de HTML inséré tel quel).
68+pub struct AnnouncementView {
69+ pub title: String,
70+ pub date: String,
71+ pub paragraphs: Vec<String>,
72+}
73+
74+/// Page interstitielle hors shell (pas de tab bar : les deux boutons du form
75+/// sont le seul chemin de sortie, et fermer = marquer lu).
76+#[derive(Template)]
77+#[template(path = "annonces.html")]
78+pub struct AnnoncesPage {
79+ pub tr: &'static crate::i18n::Tr,
80+ pub lang_code: &'static str,
81+ pub hc: bool,
82+ pub csrf: String,
83+ pub retour: String,
84+ /// Les ids affichés, joints par des virgules (champ caché du form —
85+ /// axum::Form ne déserialise pas les champs répétés).
86+ pub ids: String,
87+ pub items: Vec<AnnouncementView>,
88+}
89+
90+#[derive(Deserialize)]
91+pub struct AnnoncesQuery {
92+ retour: Option<String>,
93+}
94+
95+/// GET /annonces — la page n'existe que pleine : rien à lire → accueil.
96+pub async fn annonces_page(
97+ State(state): State<AppState>,
98+ ctx: ActiveTeam,
99+ Query(q): Query<AnnoncesQuery>,
100+) -> Result<Response, AppError> {
101+ let rows = changelog::unread(&state.pool, ctx.user.id).await?;
102+ if rows.is_empty() {
103+ return Ok(Redirect::to("/").into_response());
104+ }
105+ let tr = ctx.tr();
106+ let ids = rows
107+ .iter()
108+ .map(|a| a.id.to_string())
109+ .collect::<Vec<_>>()
110+ .join(",");
111+ let items = rows
112+ .into_iter()
113+ .map(|a| {
114+ let text = match ctx.lang {
115+ Lang::Fr => a.text_fr,
116+ Lang::En => a.text_en,
117+ Lang::De => a.text_de,
118+ Lang::PtBr => a.text_ptbr,
119+ };
120+ AnnouncementView {
121+ title: p1(tr.changelog_card_title, &a.version),
122+ date: crate::period::day_full(ctx.lang, a.date),
123+ paragraphs: text
124+ .lines()
125+ .map(str::trim)
126+ .filter(|l| !l.is_empty())
127+ .map(String::from)
128+ .collect(),
129+ }
130+ })
131+ .collect();
132+ Ok(HtmlTemplate(AnnoncesPage {
133+ tr,
134+ lang_code: ctx.lang.code(),
135+ hc: ctx.hc,
136+ csrf: ctx.user.csrf_token(&state),
137+ retour: super::sanitize_return(q.retour.as_deref(), "/temps/nouveau").to_string(),
138+ ids,
139+ items,
140+ })
141+ .into_response())
142+}
143+
144+#[derive(Deserialize)]
145+pub struct CloseForm {
146+ csrf: String,
147+ retour: Option<String>,
148+ ids: String,
149+}
150+
151+/// POST /annonces — fermer = marquer lu, puis retour à la destination
152+/// demandée. On ne marque que les ids affichés : une annonce publiée entre
153+/// le rendu et le POST réapparaîtra (course bénigne, assumée). Pas de toast.
154+pub async fn annonces_close(
155+ State(state): State<AppState>,
156+ ctx: ActiveTeam,
157+ Form(form): Form<CloseForm>,
158+) -> Result<Redirect, AppError> {
159+ if !ctx.user.check_csrf(&state, &form.csrf) {
160+ return Err(AppError::Csrf);
161+ }
162+ let ids: Vec<Uuid> = form
163+ .ids
164+ .split(',')
165+ .filter_map(|s| s.trim().parse().ok())
166+ .collect();
167+ changelog::mark_read(&state.pool, ctx.user.id, &ids).await?;
168+ Ok(Redirect::to(super::sanitize_return(
169+ form.retour.as_deref(),
170+ "/temps/nouveau",
171+ )))
172+}
new file mode 100644
@@ -0,0 +1,172 @@
1+use askama::Template;
2+use axum::Form;
3+use axum::extract::{FromRequestParts, Query, State};
4+use axum::http::request::Parts;
5+use axum::response::{IntoResponse, Redirect, Response};
6+use serde::Deserialize;
7+use uuid::Uuid;
8+
9+use crate::db::changelog;
10+use crate::error::AppError;
11+use crate::i18n::{Lang, p1};
12+use crate::render::HtmlTemplate;
13+use crate::state::AppState;
14+use crate::team::ActiveTeam;
15+
16+/// Garde des cinq racines d'onglet : un GET « nu » (aucune query string) est
17+/// redirigé vers /annonces s'il reste des annonces non lues. Une query
18+/// signale un flow en cours (?categorie=, ?ok= des toasts, ?page=,
19+/// ?semaine=…) qu'on n'interrompt jamais — c'est la navigation entre vues
20+/// qui déclenche, pas les étapes d'un parcours.
21+///
22+/// Les requêtes non authentifiées passent sans bruit : c'est AuthUser /
23+/// ActiveTeam, plus loin dans le handler, qui produira le redirect /login.
24+pub struct ChangelogGate;
25+
26+pub enum GateRejection {
27+ /// Des annonces attendent — porte le chemin demandé, à retrouver après
28+ /// fermeture.
29+ Announce(String),
30+ App(AppError),
31+}
32+
33+impl IntoResponse for GateRejection {
34+ fn into_response(self) -> Response {
35+ match self {
36+ GateRejection::Announce(path) => {
37+ Redirect::to(&format!("/annonces?retour={}", super::urlenc(&path)))
38+ .into_response()
39+ }
40+ GateRejection::App(e) => e.into_response(),
41+ }
42+ }
43+}
44+
45+impl FromRequestParts<AppState> for ChangelogGate {
46+ type Rejection = GateRejection;
47+
48+ async fn from_request_parts(
49+ parts: &mut Parts,
50+ state: &AppState,
51+ ) -> Result<Self, GateRejection> {
52+ if parts.uri.query().is_some() {
53+ return Ok(ChangelogGate);
54+ }
55+ let Some(user) = crate::auth::extractor::authenticate(parts, state) else {
56+ return Ok(ChangelogGate);
57+ };
58+ match changelog::has_unread(&state.pool, user.id).await {
59+ Ok(true) => Err(GateRejection::Announce(parts.uri.path().to_string())),
60+ Ok(false) => Ok(ChangelogGate),
61+ Err(e) => Err(GateRejection::App(AppError::Db(e))),
62+ }
63+ }
64+}
65+
66+/// Une annonce prête à afficher : langue choisie, paragraphes découpés
67+/// (une ligne du texte en base = un <p> — jamais de HTML inséré tel quel).
68+pub struct AnnouncementView {
69+ pub title: String,
70+ pub date: String,
71+ pub paragraphs: Vec<String>,
72+}
73+
74+/// Page interstitielle hors shell (pas de tab bar : les deux boutons du form
75+/// sont le seul chemin de sortie, et fermer = marquer lu).
76+#[derive(Template)]
77+#[template(path = "annonces.html")]
78+pub struct AnnoncesPage {
79+ pub tr: &'static crate::i18n::Tr,
80+ pub lang_code: &'static str,
81+ pub hc: bool,
82+ pub csrf: String,
83+ pub retour: String,
84+ /// Les ids affichés, joints par des virgules (champ caché du form —
85+ /// axum::Form ne déserialise pas les champs répétés).
86+ pub ids: String,
87+ pub items: Vec<AnnouncementView>,
88+}
89+
90+#[derive(Deserialize)]
91+pub struct AnnoncesQuery {
92+ retour: Option<String>,
93+}
94+
95+/// GET /annonces — la page n'existe que pleine : rien à lire → accueil.
96+pub async fn annonces_page(
97+ State(state): State<AppState>,
98+ ctx: ActiveTeam,
99+ Query(q): Query<AnnoncesQuery>,
100+) -> Result<Response, AppError> {
101+ let rows = changelog::unread(&state.pool, ctx.user.id).await?;
102+ if rows.is_empty() {
103+ return Ok(Redirect::to("/").into_response());
104+ }
105+ let tr = ctx.tr();
106+ let ids = rows
107+ .iter()
108+ .map(|a| a.id.to_string())
109+ .collect::<Vec<_>>()
110+ .join(",");
111+ let items = rows
112+ .into_iter()
113+ .map(|a| {
114+ let text = match ctx.lang {
115+ Lang::Fr => a.text_fr,
116+ Lang::En => a.text_en,
117+ Lang::De => a.text_de,
118+ Lang::PtBr => a.text_ptbr,
119+ };
120+ AnnouncementView {
121+ title: p1(tr.changelog_card_title, &a.version),
122+ date: crate::period::day_full(ctx.lang, a.date),
123+ paragraphs: text
124+ .lines()
125+ .map(str::trim)
126+ .filter(|l| !l.is_empty())
127+ .map(String::from)
128+ .collect(),
129+ }
130+ })
131+ .collect();
132+ Ok(HtmlTemplate(AnnoncesPage {
133+ tr,
134+ lang_code: ctx.lang.code(),
135+ hc: ctx.hc,
136+ csrf: ctx.user.csrf_token(&state),
137+ retour: super::sanitize_return(q.retour.as_deref(), "/temps/nouveau").to_string(),
138+ ids,
139+ items,
140+ })
141+ .into_response())
142+}
143+
144+#[derive(Deserialize)]
145+pub struct CloseForm {
146+ csrf: String,
147+ retour: Option<String>,
148+ ids: String,
149+}
150+
151+/// POST /annonces — fermer = marquer lu, puis retour à la destination
152+/// demandée. On ne marque que les ids affichés : une annonce publiée entre
153+/// le rendu et le POST réapparaîtra (course bénigne, assumée). Pas de toast.
154+pub async fn annonces_close(
155+ State(state): State<AppState>,
156+ ctx: ActiveTeam,
157+ Form(form): Form<CloseForm>,
158+) -> Result<Redirect, AppError> {
159+ if !ctx.user.check_csrf(&state, &form.csrf) {
160+ return Err(AppError::Csrf);
161+ }
162+ let ids: Vec<Uuid> = form
163+ .ids
164+ .split(',')
165+ .filter_map(|s| s.trim().parse().ok())
166+ .collect();
167+ changelog::mark_read(&state.pool, ctx.user.id, &ids).await?;
168+ Ok(Redirect::to(super::sanitize_return(
169+ form.retour.as_deref(),
170+ "/temps/nouveau",
171+ )))
172+}
modified src/routes/entries.rs +2 -0
@@ -118,6 +118,7 @@ fn step2_href(categorie: Uuid, minutes: i32, date: NaiveDate) -> String {
118118
119119 pub async fn entry_new(
120120 State(state): State<AppState>,
121+ _gate: super::changelog::ChangelogGate,
121122 ctx: ActiveTeam,
122123 Query(q): Query<NewQuery>,
123124 ) -> Result<Response, AppError> {
@@ -321,6 +322,7 @@ pub struct PageQuery {
321322
322323 pub async fn entries_list(
323324 State(state): State<AppState>,
325+ _gate: super::changelog::ChangelogGate,
324326 ctx: ActiveTeam,
325327 Query(q): Query<PageQuery>,
326328 ) -> Result<HtmlTemplate<TimesPage>, AppError> {
@@ -118,6 +118,7 @@ fn step2_href(categorie: Uuid, minutes: i32, date: NaiveDate) -> String {
118 118
119 pub async fn entry_new(119 pub async fn entry_new(
120 State(state): State<AppState>,120 State(state): State<AppState>,
121+ _gate: super::changelog::ChangelogGate,
121 ctx: ActiveTeam,122 ctx: ActiveTeam,
122 Query(q): Query<NewQuery>,123 Query(q): Query<NewQuery>,
123 ) -> Result<Response, AppError> {124 ) -> Result<Response, AppError> {
@@ -321,6 +322,7 @@ pub struct PageQuery {
321 322
322 pub async fn entries_list(323 pub async fn entries_list(
323 State(state): State<AppState>,324 State(state): State<AppState>,
325+ _gate: super::changelog::ChangelogGate,
324 ctx: ActiveTeam,326 ctx: ActiveTeam,
325 Query(q): Query<PageQuery>,327 Query(q): Query<PageQuery>,
326 ) -> Result<HtmlTemplate<TimesPage>, AppError> {328 ) -> Result<HtmlTemplate<TimesPage>, AppError> {
modified src/routes/mod.rs +33 -0
@@ -1,3 +1,4 @@
1+pub(crate) mod changelog;
12 mod entries;
23 pub(crate) mod login;
34 mod security;
@@ -40,6 +41,10 @@ pub fn router(state: AppState) -> Router {
4041 get(entries::entries_list).post(entries::entry_create),
4142 )
4243 .route("/temps/nouveau", get(entries::entry_new))
44+ .route(
45+ "/annonces",
46+ get(changelog::annonces_page).post(changelog::annonces_close),
47+ )
4348 .route("/temps/{id}/modifier", get(entries::entry_edit))
4449 .route("/temps/{id}", post(entries::entry_update))
4550 .route("/temps/{id}/supprimer", post(entries::entry_delete))
@@ -304,6 +309,15 @@ pub(crate) fn public_origin(headers: &axum::http::HeaderMap) -> String {
304309 format!("{scheme}://{host}")
305310 }
306311
312+/// Valide un chemin de retour fourni par le client (`?retour=` / champ
313+/// caché) : chemin interne uniquement — ni URL absolue, ni `//` relatif au
314+/// protocole — sinon le défaut. Partagé par timer, équipes et annonces.
315+pub(crate) fn sanitize_return<'a>(retour: Option<&'a str>, default: &'a str) -> &'a str {
316+ retour
317+ .filter(|p| p.starts_with('/') && !p.starts_with("//"))
318+ .unwrap_or(default)
319+}
320+
307321 pub(crate) fn urlenc(s: &str) -> String {
308322 let mut out = String::with_capacity(s.len());
309323 for b in s.bytes() {
@@ -316,3 +330,22 @@ pub(crate) fn urlenc(s: &str) -> String {
316330 }
317331 out
318332 }
333+
334+#[cfg(test)]
335+mod tests {
336+ use super::sanitize_return;
337+
338+ #[test]
339+ fn sanitize_return_ne_garde_que_les_chemins_internes() {
340+ assert_eq!(sanitize_return(Some("/stats"), "/defaut"), "/stats");
341+ assert_eq!(
342+ sanitize_return(Some("/temps?page=2"), "/defaut"),
343+ "/temps?page=2"
344+ );
345+ // URL absolue, protocole-relative ou vide : open redirect refusé.
346+ assert_eq!(sanitize_return(Some("https://evil.example"), "/defaut"), "/defaut");
347+ assert_eq!(sanitize_return(Some("//evil.example"), "/defaut"), "/defaut");
348+ assert_eq!(sanitize_return(Some(""), "/defaut"), "/defaut");
349+ assert_eq!(sanitize_return(None, "/defaut"), "/defaut");
350+ }
351+}
@@ -1,3 +1,4 @@
1+pub(crate) mod changelog;
1 mod entries;2 mod entries;
2 pub(crate) mod login;3 pub(crate) mod login;
3 mod security;4 mod security;
@@ -40,6 +41,10 @@ pub fn router(state: AppState) -> Router {
40 get(entries::entries_list).post(entries::entry_create),41 get(entries::entries_list).post(entries::entry_create),
41 )42 )
42 .route("/temps/nouveau", get(entries::entry_new))43 .route("/temps/nouveau", get(entries::entry_new))
44+ .route(
45+ "/annonces",
46+ get(changelog::annonces_page).post(changelog::annonces_close),
47+ )
43 .route("/temps/{id}/modifier", get(entries::entry_edit))48 .route("/temps/{id}/modifier", get(entries::entry_edit))
44 .route("/temps/{id}", post(entries::entry_update))49 .route("/temps/{id}", post(entries::entry_update))
45 .route("/temps/{id}/supprimer", post(entries::entry_delete))50 .route("/temps/{id}/supprimer", post(entries::entry_delete))
@@ -304,6 +309,15 @@ pub(crate) fn public_origin(headers: &axum::http::HeaderMap) -> String {
304 format!("{scheme}://{host}")309 format!("{scheme}://{host}")
305 }310 }
306 311
312+/// Valide un chemin de retour fourni par le client (`?retour=` / champ
313+/// caché) : chemin interne uniquement — ni URL absolue, ni `//` relatif au
314+/// protocole — sinon le défaut. Partagé par timer, équipes et annonces.
315+pub(crate) fn sanitize_return<'a>(retour: Option<&'a str>, default: &'a str) -> &'a str {
316+ retour
317+ .filter(|p| p.starts_with('/') && !p.starts_with("//"))
318+ .unwrap_or(default)
319+}
320+
307 pub(crate) fn urlenc(s: &str) -> String {321 pub(crate) fn urlenc(s: &str) -> String {
308 let mut out = String::with_capacity(s.len());322 let mut out = String::with_capacity(s.len());
309 for b in s.bytes() {323 for b in s.bytes() {
@@ -316,3 +330,22 @@ pub(crate) fn urlenc(s: &str) -> String {
316 }330 }
317 out331 out
318 }332 }
333+
334+#[cfg(test)]
335+mod tests {
336+ use super::sanitize_return;
337+
338+ #[test]
339+ fn sanitize_return_ne_garde_que_les_chemins_internes() {
340+ assert_eq!(sanitize_return(Some("/stats"), "/defaut"), "/stats");
341+ assert_eq!(
342+ sanitize_return(Some("/temps?page=2"), "/defaut"),
343+ "/temps?page=2"
344+ );
345+ // URL absolue, protocole-relative ou vide : open redirect refusé.
346+ assert_eq!(sanitize_return(Some("https://evil.example"), "/defaut"), "/defaut");
347+ assert_eq!(sanitize_return(Some("//evil.example"), "/defaut"), "/defaut");
348+ assert_eq!(sanitize_return(Some(""), "/defaut"), "/defaut");
349+ assert_eq!(sanitize_return(None, "/defaut"), "/defaut");
350+ }
351+}
modified src/routes/settings.rs +1 -0
@@ -85,6 +85,7 @@ pub struct SettingsQuery {
8585
8686 pub async fn settings_page(
8787 State(state): State<AppState>,
88+ _gate: super::changelog::ChangelogGate,
8889 ctx: ActiveTeam,
8990 Query(q): Query<SettingsQuery>,
9091 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
@@ -85,6 +85,7 @@ pub struct SettingsQuery {
85 85
86 pub async fn settings_page(86 pub async fn settings_page(
87 State(state): State<AppState>,87 State(state): State<AppState>,
88+ _gate: super::changelog::ChangelogGate,
88 ctx: ActiveTeam,89 ctx: ActiveTeam,
89 Query(q): Query<SettingsQuery>,90 Query(q): Query<SettingsQuery>,
90 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {91 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
modified src/routes/stats.rs +1 -0
@@ -68,6 +68,7 @@ fn href(week: Week, month: Month, year: Year) -> String {
6868
6969 pub async fn stats_page(
7070 State(state): State<AppState>,
71+ _gate: super::changelog::ChangelogGate,
7172 ctx: ActiveTeam,
7273 Query(q): Query<StatsQuery>,
7374 ) -> Result<HtmlTemplate<StatsPage>, AppError> {
@@ -68,6 +68,7 @@ fn href(week: Week, month: Month, year: Year) -> String {
68 68
69 pub async fn stats_page(69 pub async fn stats_page(
70 State(state): State<AppState>,70 State(state): State<AppState>,
71+ _gate: super::changelog::ChangelogGate,
71 ctx: ActiveTeam,72 ctx: ActiveTeam,
72 Query(q): Query<StatsQuery>,73 Query(q): Query<StatsQuery>,
73 ) -> Result<HtmlTemplate<StatsPage>, AppError> {74 ) -> Result<HtmlTemplate<StatsPage>, AppError> {
modified src/routes/teams.rs +1 -5
@@ -66,11 +66,7 @@ pub async fn team_switch(
6666 {
6767 return Err(AppError::NotFound);
6868 }
69- let back = form
70- .retour
71- .as_deref()
72- .filter(|p| p.starts_with('/') && !p.starts_with("//"))
73- .unwrap_or("/parametres");
69+ let back = super::sanitize_return(form.retour.as_deref(), "/parametres");
7470 Ok((jar.add(team_cookie(form.equipe)), Redirect::to(back)).into_response())
7571 }
7672
@@ -66,11 +66,7 @@ pub async fn team_switch(
66 {66 {
67 return Err(AppError::NotFound);67 return Err(AppError::NotFound);
68 }68 }
69- let back = form69+ let back = super::sanitize_return(form.retour.as_deref(), "/parametres");
70- .retour
71- .as_deref()
72- .filter(|p| p.starts_with('/') && !p.starts_with("//"))
73- .unwrap_or("/parametres");
74 Ok((jar.add(team_cookie(form.equipe)), Redirect::to(back)).into_response())70 Ok((jar.add(team_cookie(form.equipe)), Redirect::to(back)).into_response())
75 }71 }
76 72
modified src/routes/timer.rs +1 -5
@@ -27,11 +27,7 @@ pub async fn timer_start(
2727 return Err(AppError::Csrf);
2828 }
2929 timers::start(&state.pool, ctx.user.id, ctx.team_id).await?;
30- let back = form
31- .retour
32- .as_deref()
33- .filter(|p| p.starts_with('/') && !p.starts_with("//"))
34- .unwrap_or("/temps/nouveau");
30+ let back = super::sanitize_return(form.retour.as_deref(), "/temps/nouveau");
3531 Ok(Redirect::to(back))
3632 }
3733
@@ -27,11 +27,7 @@ pub async fn timer_start(
27 return Err(AppError::Csrf);27 return Err(AppError::Csrf);
28 }28 }
29 timers::start(&state.pool, ctx.user.id, ctx.team_id).await?;29 timers::start(&state.pool, ctx.user.id, ctx.team_id).await?;
30- let back = form30+ let back = super::sanitize_return(form.retour.as_deref(), "/temps/nouveau");
31- .retour
32- .as_deref()
33- .filter(|p| p.starts_with('/') && !p.starts_with("//"))
34- .unwrap_or("/temps/nouveau");
35 Ok(Redirect::to(back))31 Ok(Redirect::to(back))
36 }32 }
37 33
modified src/routes/todos.rs +1 -0
@@ -108,6 +108,7 @@ fn list_href(doing_on: bool, open_on: bool) -> String {
108108
109109 pub async fn todos_page(
110110 State(state): State<AppState>,
111+ _gate: super::changelog::ChangelogGate,
111112 ctx: ActiveTeam,
112113 Query(q): Query<ListQuery>,
113114 ) -> Result<HtmlTemplate<TodosPage>, AppError> {
@@ -108,6 +108,7 @@ fn list_href(doing_on: bool, open_on: bool) -> String {
108 108
109 pub async fn todos_page(109 pub async fn todos_page(
110 State(state): State<AppState>,110 State(state): State<AppState>,
111+ _gate: super::changelog::ChangelogGate,
111 ctx: ActiveTeam,112 ctx: ActiveTeam,
112 Query(q): Query<ListQuery>,113 Query(q): Query<ListQuery>,
113 ) -> Result<HtmlTemplate<TodosPage>, AppError> {114 ) -> Result<HtmlTemplate<TodosPage>, AppError> {
added templates/annonces.html +32 -0
new file mode 100644
@@ -0,0 +1,32 @@
1+{% extends "base.html" %}
2+{% block title %}{{ tr.title_changelog }} · Ex Ego{% endblock %}
3+{% block lang %}{{ lang_code }}{% endblock %}
4+{% block themeattr %}{% if hc %} data-theme="hc"{% endif %}{% endblock %}
5+{% block themecolor %}{% if hc %}#000000{% else %}#f5ead8{% endif %}{% endblock %}
6+{% block body %}
7+<main class="login announce">
8+ {# Page interstitielle hors shell : un seul form, deux submits (« Fermer »
9+ en haut, « OK » en bas) — même action, fermer = marquer lu. #}
10+ <form method="post" action="/annonces">
11+ <input type="hidden" name="csrf" value="{{ csrf }}">
12+ <input type="hidden" name="retour" value="{{ retour }}">
13+ <input type="hidden" name="ids" value="{{ ids }}">
14+ <div class="announce-head">
15+ <h1>{{ tr.title_changelog }}</h1>
16+ <button type="submit" class="btn btn-ghost">{{ tr.changelog_close }}</button>
17+ </div>
18+ <div class="announce-list">
19+ {% for a in items %}
20+ <article class="card announce-card">
21+ <h2>{{ a.title }}</h2>
22+ <p class="announce-date">{{ a.date }}</p>
23+ {% for p in a.paragraphs %}
24+ <p class="announce-text">{{ p }}</p>
25+ {% endfor %}
26+ </article>
27+ {% endfor %}
28+ </div>
29+ <button type="submit" class="btn btn-primary btn-block announce-ok">{{ tr.changelog_ok }}</button>
30+ </form>
31+</main>
32+{% endblock %}
new file mode 100644
@@ -0,0 +1,32 @@
1+{% extends "base.html" %}
2+{% block title %}{{ tr.title_changelog }} · Ex Ego{% endblock %}
3+{% block lang %}{{ lang_code }}{% endblock %}
4+{% block themeattr %}{% if hc %} data-theme="hc"{% endif %}{% endblock %}
5+{% block themecolor %}{% if hc %}#000000{% else %}#f5ead8{% endif %}{% endblock %}
6+{% block body %}
7+<main class="login announce">
8+ {# Page interstitielle hors shell : un seul form, deux submits (« Fermer »
9+ en haut, « OK » en bas) — même action, fermer = marquer lu. #}
10+ <form method="post" action="/annonces">
11+ <input type="hidden" name="csrf" value="{{ csrf }}">
12+ <input type="hidden" name="retour" value="{{ retour }}">
13+ <input type="hidden" name="ids" value="{{ ids }}">
14+ <div class="announce-head">
15+ <h1>{{ tr.title_changelog }}</h1>
16+ <button type="submit" class="btn btn-ghost">{{ tr.changelog_close }}</button>
17+ </div>
18+ <div class="announce-list">
19+ {% for a in items %}
20+ <article class="card announce-card">
21+ <h2>{{ a.title }}</h2>
22+ <p class="announce-date">{{ a.date }}</p>
23+ {% for p in a.paragraphs %}
24+ <p class="announce-text">{{ p }}</p>
25+ {% endfor %}
26+ </article>
27+ {% endfor %}
28+ </div>
29+ <button type="submit" class="btn btn-primary btn-block announce-ok">{{ tr.changelog_ok }}</button>
30+ </form>
31+</main>
32+{% endblock %}