feat(changelog): annonces de nouveautés — interstitiel /annonces, gate des racines nues, migration 0016Unverified
b0ed1ae parent: 6cfca47 modified
a11y/audit.mjs +29 -5 | @@ -1,6 +1,6 @@ | ||
| 1 | 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 échoue | |
| 3 | +// Parcourt les 16 écrans de l'app (viewport mobile 390×844) et échoue | |
| 4 | 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; |
| 5 | 5 | // les violations moindres sont listées en avertissement. |
| 6 | 6 | // |
| @@ -13,6 +13,15 @@ | ||
| 13 | 13 | // cargo run -- user join audit --team "Audit 2" |
| 14 | 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 | 25 | // Usage : |
| 17 | 26 | // cd a11y && npm install |
| 18 | 27 | // TT_USER=audit TT_PASS=xxxxxxxx npm run audit |
| @@ -57,15 +66,20 @@ if (page.url().includes('/login')) { | ||
| 57 | 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 | 73 | const ids = await page.evaluate(async () => { |
| 62 | 74 | 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'); | |
| 65 | 78 | return { |
| 66 | 79 | categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null, |
| 67 | 80 | entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null, |
| 68 | 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 | 88 | // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec |
| 75 | 89 | // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription. |
| 76 | 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 | 100 | { name: 'add-choice', url: '/temps/nouveau' }, |
| 78 | 101 | { name: 'add-step1', url: '/temps/nouveau?mode=manuel' }, |
| 79 | 102 | ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` }, |
| @@ -90,7 +113,7 @@ const pages = [ | ||
| 90 | 113 | { name: 'todo-new', url: '/taches/nouvelle' }, |
| 91 | 114 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, |
| 92 | 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 | 118 | let blocking = 0; |
| 96 | 119 | for (const p of pages) { |
| @@ -117,6 +140,7 @@ for (const p of pages) { | ||
| 117 | 140 | console.log(`${hard ? '✘' : '⚠'} ${p.name}: [${v.impact}] ${v.id} ×${v.count} — ${v.help}`); |
| 118 | 141 | for (const t of v.targets) console.log(` · ${t}`); |
| 119 | 142 | } |
| 143 | + if (p.then) await p.then(pg); | |
| 120 | 144 | if (ctx) await ctx.close(); |
| 121 | 145 | } |
| 122 | 146 | |
| @@ -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 échoue | 3 | +// 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 install | 26 | // cd a11y && npm install |
| 18 | // TT_USER=audit TT_PASS=xxxxxxxx npm run audit | 27 | // 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 avec | 88 | // 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); } | ||
| 424 | 424 | .signup h1 { font-size: 34px; } |
| 425 | 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 | 449 | /* — ajout étape 1 : liste de catégories — */ |
| 428 | 450 | .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); } |
| 429 | 451 | .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; | |
| 1 | 2 | pub mod entries; |
| 2 | 3 | pub mod passkeys; |
| 3 | 4 | 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 { | ||
| 89 | 89 | /// Pastille d'action du dock du minuteur. |
| 90 | 90 | pub stop_cta: &'static str, |
| 91 | 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 | 99 | // ─── login ─── |
| 93 | 100 | pub login_title: &'static str, |
| 94 | 101 | pub tagline: &'static str, |
| @@ -449,6 +456,10 @@ pub static FR: Tr = Tr { | ||
| 449 | 456 | timer_in_team_pat: "minuteur en cours dans l'équipe {}", |
| 450 | 457 | stop_cta: "Arrêter", |
| 451 | 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 | 463 | login_title: "Connexion", |
| 453 | 464 | tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.", |
| 454 | 465 | username_label: "Identifiant", |
| @@ -748,6 +759,10 @@ pub static EN: Tr = Tr { | ||
| 748 | 759 | timer_in_team_pat: "timer running in team {}", |
| 749 | 760 | stop_cta: "Stop", |
| 750 | 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 | 766 | login_title: "Sign in", |
| 752 | 767 | tagline: "Time spent on each category, shared with your team.", |
| 753 | 768 | username_label: "Username", |
| @@ -1047,6 +1062,10 @@ pub static DE: Tr = Tr { | ||
| 1047 | 1062 | timer_in_team_pat: "Timer läuft im Team {}", |
| 1048 | 1063 | stop_cta: "Stoppen", |
| 1049 | 1064 | record_cta: "Erfassen", |
| 1065 | + title_changelog: "Neuigkeiten", | |
| 1066 | + changelog_card_title: "Neu in {}", | |
| 1067 | + changelog_close: "Schließen", | |
| 1068 | + changelog_ok: "OK", | |
| 1050 | 1069 | login_title: "Anmeldung", |
| 1051 | 1070 | tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.", |
| 1052 | 1071 | username_label: "Benutzername", |
| @@ -1346,6 +1365,10 @@ pub static PT_BR: Tr = Tr { | ||
| 1346 | 1365 | timer_in_team_pat: "cronômetro em andamento na equipe {}", |
| 1347 | 1366 | stop_cta: "Parar", |
| 1348 | 1367 | record_cta: "Registrar", |
| 1368 | + title_changelog: "Novidades", | |
| 1369 | + changelog_card_title: "Novidades da {}", | |
| 1370 | + changelog_close: "Fechar", | |
| 1371 | + changelog_ok: "OK", | |
| 1349 | 1372 | login_title: "Entrar", |
| 1350 | 1373 | tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.", |
| 1351 | 1374 | 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 { | ||
| 118 | 118 | |
| 119 | 119 | pub async fn entry_new( |
| 120 | 120 | State(state): State<AppState>, |
| 121 | + _gate: super::changelog::ChangelogGate, | |
| 121 | 122 | ctx: ActiveTeam, |
| 122 | 123 | Query(q): Query<NewQuery>, |
| 123 | 124 | ) -> Result<Response, AppError> { |
| @@ -321,6 +322,7 @@ pub struct PageQuery { | ||
| 321 | 322 | |
| 322 | 323 | pub async fn entries_list( |
| 323 | 324 | State(state): State<AppState>, |
| 325 | + _gate: super::changelog::ChangelogGate, | |
| 324 | 326 | ctx: ActiveTeam, |
| 325 | 327 | Query(q): Query<PageQuery>, |
| 326 | 328 | ) -> 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; | |
| 1 | 2 | mod entries; |
| 2 | 3 | pub(crate) mod login; |
| 3 | 4 | mod security; |
| @@ -40,6 +41,10 @@ pub fn router(state: AppState) -> Router { | ||
| 40 | 41 | get(entries::entries_list).post(entries::entry_create), |
| 41 | 42 | ) |
| 42 | 43 | .route("/temps/nouveau", get(entries::entry_new)) |
| 44 | + .route( | |
| 45 | + "/annonces", | |
| 46 | + get(changelog::annonces_page).post(changelog::annonces_close), | |
| 47 | + ) | |
| 43 | 48 | .route("/temps/{id}/modifier", get(entries::entry_edit)) |
| 44 | 49 | .route("/temps/{id}", post(entries::entry_update)) |
| 45 | 50 | .route("/temps/{id}/supprimer", post(entries::entry_delete)) |
| @@ -304,6 +309,15 @@ pub(crate) fn public_origin(headers: &axum::http::HeaderMap) -> String { | ||
| 304 | 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 | 321 | pub(crate) fn urlenc(s: &str) -> String { |
| 308 | 322 | let mut out = String::with_capacity(s.len()); |
| 309 | 323 | for b in s.bytes() { |
| @@ -316,3 +330,22 @@ pub(crate) fn urlenc(s: &str) -> String { | ||
| 316 | 330 | } |
| 317 | 331 | 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 | +} | |
| @@ -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 | out | 331 | 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 { | ||
| 85 | 85 | |
| 86 | 86 | pub async fn settings_page( |
| 87 | 87 | State(state): State<AppState>, |
| 88 | + _gate: super::changelog::ChangelogGate, | |
| 88 | 89 | ctx: ActiveTeam, |
| 89 | 90 | Query(q): Query<SettingsQuery>, |
| 90 | 91 | ) -> 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 { | ||
| 68 | 68 | |
| 69 | 69 | pub async fn stats_page( |
| 70 | 70 | State(state): State<AppState>, |
| 71 | + _gate: super::changelog::ChangelogGate, | |
| 71 | 72 | ctx: ActiveTeam, |
| 72 | 73 | Query(q): Query<StatsQuery>, |
| 73 | 74 | ) -> 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( | ||
| 66 | 66 | { |
| 67 | 67 | return Err(AppError::NotFound); |
| 68 | 68 | } |
| 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"); | |
| 74 | 70 | Ok((jar.add(team_cookie(form.equipe)), Redirect::to(back)).into_response()) |
| 75 | 71 | } |
| 76 | 72 | |
| @@ -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 = form | 69 | + 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( | ||
| 27 | 27 | return Err(AppError::Csrf); |
| 28 | 28 | } |
| 29 | 29 | 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"); | |
| 35 | 31 | Ok(Redirect::to(back)) |
| 36 | 32 | } |
| 37 | 33 | |
| @@ -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 = form | 30 | + 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 { | ||
| 108 | 108 | |
| 109 | 109 | pub async fn todos_page( |
| 110 | 110 | State(state): State<AppState>, |
| 111 | + _gate: super::changelog::ChangelogGate, | |
| 111 | 112 | ctx: ActiveTeam, |
| 112 | 113 | Query(q): Query<ListQuery>, |
| 113 | 114 | ) -> 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 %} | ||