Favoris unifies « Mon univers Ka » (hub KA ID) : coeur sur cartes et fiche, page /favoris
- jobka/hubfav.py : client du magasin central de favoris du hub Groupe KA (HMAC signe, toggle synchrone, lecture cachee 30 s, zero stockage local) - jobka/web.py : GET /api/favorites + POST /api/favorites/toggle (session KA ID requise, 401 sinon) ; route SPA /favoris - jobka/auth.py : parametre next (chemin local, transporte dans le state signe) pour revenir a la page courante apres la connexion KA ID - frontend : favorites.tsx (FavProvider + FavButton), coeur sur JobCard et la fiche /emploi/:uid, page /favoris + liens nav et prefooter, CSS fav-btn Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 changed files +427 −15
modified
frontend/src/App.tsx
+7 −2
@@ -18,6 +18,8 @@ import Stats from "./pages/Stats"; | ||
| 18 | 18 | import Contact from "./pages/Contact"; |
| 19 | 19 | import Employeurs from "./pages/Employeurs"; |
| 20 | 20 | import VillePage, { VillesPage } from "./pages/Ville"; |
| 21 | +import FavorisPage from "./pages/Favoris"; | |
| 22 | +import { FavProvider } from "./favorites"; | |
| 21 | 23 | import GroupeKaBadge from "./ka/GroupeKaBadge"; |
| 22 | 24 | import KaFooter from "./ka/KaFooter"; |
| 23 | 25 | |
@@ -69,6 +71,7 @@ function KaAuth() { | ||
| 69 | 71 | const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [ |
| 70 | 72 | { to: "/", label: "Offres", end: true }, |
| 71 | 73 | { to: "/carte", label: "Carte" }, |
| 74 | + { to: "/favoris", label: "Favoris ♥" }, | |
| 72 | 75 | { to: "/sources", label: "Employeurs" }, |
| 73 | 76 | { to: "/stats", label: "Stats" }, |
| 74 | 77 | { to: "/employeurs", label: "Publier" }, |
@@ -113,7 +116,7 @@ export default function App() { | ||
| 113 | 116 | }, [menuOpen]); |
| 114 | 117 | |
| 115 | 118 | return ( |
| 116 | − <> | |
| 119 | + <FavProvider> | |
| 117 | 120 | <header className={menuOpen ? "site menu-open" : "site"}> |
| 118 | 121 | <div className="container"> |
| 119 | 122 | <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink> |
@@ -169,6 +172,7 @@ export default function App() { | ||
| 169 | 172 | </Suspense> |
| 170 | 173 | } /> |
| 171 | 174 | <Route path="/emploi/:uid" element={<JobPage />} /> |
| 175 | + <Route path="/favoris" element={<FavorisPage />} /> | |
| 172 | 176 | <Route path="/villes" element={<VillesPage />} /> |
| 173 | 177 | <Route path="/ville/:ville" element={<VillePage />} /> |
| 174 | 178 | <Route path="/sources" element={<Sources />} /> |
@@ -185,6 +189,7 @@ export default function App() { | ||
| 185 | 189 | boîte noire. |
| 186 | 190 | </span> |
| 187 | 191 | <NavLink to="/">Offres</NavLink> |
| 192 | + <NavLink to="/favoris">Mes favoris</NavLink> | |
| 188 | 193 | <NavLink to="/villes">Villes</NavLink> |
| 189 | 194 | <NavLink to="/carte">Carte</NavLink> |
| 190 | 195 | <NavLink to="/sources">Employeurs</NavLink> |
@@ -194,6 +199,6 @@ export default function App() { | ||
| 194 | 199 | </div> |
| 195 | 200 | </div> |
| 196 | 201 | <KaFooter siteId="job-ka" /> |
| 197 | − </> | |
| 202 | + </FavProvider> | |
| 198 | 203 | ); |
| 199 | 204 | } |
modified
frontend/src/components/JobCard.tsx
+5 −2
@@ -4,17 +4,20 @@ | ||
| 4 | 4 | * Auteur : Simon-Pierre Boucher |
| 5 | 5 | * Contact : contact@spboucher.ai |
| 6 | 6 | * Fichier : frontend/src/components/JobCard.tsx |
| 7 | − * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta) | |
| 8 | − * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 7 | + * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta, | |
| 8 | + * cœur ♥ favoris « Mon univers Ka ») | |
| 9 | + * Créé : 2026-08-17 Modifié : 2026-08-23 | |
| 9 | 10 | * ============================================================================= |
| 10 | 11 | */ |
| 11 | 12 | import { Link } from "react-router-dom"; |
| 12 | 13 | import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; |
| 14 | +import { FavButton } from "../favorites"; | |
| 13 | 15 | |
| 14 | 16 | export default function JobCard({ job }: { job: Job }) { |
| 15 | 17 | const salary = formatSalary(job); |
| 16 | 18 | return ( |
| 17 | 19 | <Link to={`/emploi/${job.uid}`} className="job-card"> |
| 20 | + <FavButton job={job} /> | |
| 18 | 21 | <h3>{displayTitle(job)}</h3> |
| 19 | 22 | <span className="employer">{job.employer}</span> |
| 20 | 23 | {job.city ? <span className="muted"> — {job.city}</span> : null} |
added
frontend/src/favorites.tsx
+139 −0
@@ -0,0 +1,139 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/favorites.tsx | |
| 7 | + * Rôle : Favoris ♥ « Mon univers Ka » — le hub Groupe KA est le magasin | |
| 8 | + * central des favoris du groupe (aucun stockage navigateur) : | |
| 9 | + * lecture/écriture via l'API locale /api/favorites qui relaie au | |
| 10 | + * hub. FavProvider charge la liste une fois ; FavButton = cœur sur | |
| 11 | + * les cartes d'offres et la fiche. Non connecté → redirection vers | |
| 12 | + * la connexion KA ID avec retour à la page courante. | |
| 13 | + * Créé : 2026-08-23 Modifié : 2026-08-23 | |
| 14 | + * ============================================================================= | |
| 15 | + */ | |
| 16 | +import { | |
| 17 | + ReactNode, createContext, useCallback, useContext, | |
| 18 | + useEffect, useMemo, useState, | |
| 19 | +} from "react"; | |
| 20 | +import { displayTitle, formatSalary, Job } from "./api"; | |
| 21 | + | |
| 22 | +/** Item de favori tel que poussé au hub Groupe KA. */ | |
| 23 | +export interface FavItem { | |
| 24 | + item_id: string; | |
| 25 | + title: string; | |
| 26 | + subtitle?: string; | |
| 27 | + price_label?: string; | |
| 28 | + image_url?: string; | |
| 29 | + url?: string; | |
| 30 | +} | |
| 31 | + | |
| 32 | +interface FavState { | |
| 33 | + ready: boolean; // premier chargement terminé | |
| 34 | + connected: boolean; // session KA ID active | |
| 35 | + ids: Set<string>; // uid des offres en favoris | |
| 36 | + items: FavItem[]; | |
| 37 | + toggle: (item: FavItem) => void; | |
| 38 | +} | |
| 39 | + | |
| 40 | +const FavContext = createContext<FavState>({ | |
| 41 | + ready: false, connected: false, ids: new Set(), items: [], toggle: () => {}, | |
| 42 | +}); | |
| 43 | + | |
| 44 | +/** Offre -> item de favori du hub (titre du poste, employeur · ville, salaire). */ | |
| 45 | +export function jobFavItem(job: Job): FavItem { | |
| 46 | + return { | |
| 47 | + item_id: job.uid, | |
| 48 | + title: displayTitle(job) || "Offre d'emploi", | |
| 49 | + subtitle: [job.employer, job.city || job.region] | |
| 50 | + .filter(Boolean).join(" · "), | |
| 51 | + price_label: formatSalary(job) || "", | |
| 52 | + image_url: job.company_logo || "", | |
| 53 | + url: `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`, | |
| 54 | + }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** Redirection vers la connexion KA ID, retour à la page courante. */ | |
| 58 | +export function goLogin() { | |
| 59 | + const next = encodeURIComponent( | |
| 60 | + window.location.pathname + window.location.search); | |
| 61 | + window.location.assign(`/api/auth/ka/login?next=${next}`); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export function FavProvider({ children }: { children: ReactNode }) { | |
| 65 | + const [ready, setReady] = useState(false); | |
| 66 | + const [connected, setConnected] = useState(false); | |
| 67 | + const [items, setItems] = useState<FavItem[]>([]); | |
| 68 | + | |
| 69 | + useEffect(() => { | |
| 70 | + fetch("/api/favorites", { credentials: "same-origin" }) | |
| 71 | + .then(async (res) => { | |
| 72 | + if (res.status === 401) return; // pas connecté : cœurs vides | |
| 73 | + if (!res.ok) throw new Error(`API ${res.status}`); | |
| 74 | + const data = (await res.json()) as { items?: FavItem[] }; | |
| 75 | + setConnected(true); | |
| 76 | + setItems(data.items ?? []); | |
| 77 | + }) | |
| 78 | + .catch(() => {}) // hub muet : cœurs vides | |
| 79 | + .finally(() => setReady(true)); | |
| 80 | + }, []); | |
| 81 | + | |
| 82 | + const toggle = useCallback((item: FavItem) => { | |
| 83 | + if (!connected) { | |
| 84 | + goLogin(); // non connecté : connexion KA ID puis retour | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + const prev = items; | |
| 88 | + const on = !prev.some((i) => i.item_id === item.item_id); | |
| 89 | + // optimiste : le hub confirme (sinon on rétablit) | |
| 90 | + setItems(on ? [...prev, item] | |
| 91 | + : prev.filter((i) => i.item_id !== item.item_id)); | |
| 92 | + fetch("/api/favorites/toggle", { | |
| 93 | + method: "POST", | |
| 94 | + credentials: "same-origin", | |
| 95 | + headers: { "Content-Type": "application/json" }, | |
| 96 | + body: JSON.stringify({ on, item }), | |
| 97 | + }) | |
| 98 | + .then((res) => { if (!res.ok) throw new Error(`API ${res.status}`); }) | |
| 99 | + .catch(() => setItems(prev)); | |
| 100 | + }, [connected, items]); | |
| 101 | + | |
| 102 | + const ids = useMemo( | |
| 103 | + () => new Set(items.map((i) => i.item_id)), [items]); | |
| 104 | + | |
| 105 | + return ( | |
| 106 | + <FavContext.Provider value={{ ready, connected, ids, items, toggle }}> | |
| 107 | + {children} | |
| 108 | + </FavContext.Provider> | |
| 109 | + ); | |
| 110 | +} | |
| 111 | + | |
| 112 | +export const useFav = () => useContext(FavContext); | |
| 113 | + | |
| 114 | +/** Cœur ♥ — cartes d'offres (par défaut) et fiche d'offre (`big`). */ | |
| 115 | +export function FavButton({ job, big = false }: { job: Job; big?: boolean }) { | |
| 116 | + const { ids, toggle } = useFav(); | |
| 117 | + const on = ids.has(job.uid); | |
| 118 | + const label = on | |
| 119 | + ? "Retirer de mes favoris (Mon univers Ka)" | |
| 120 | + : "Ajouter à mes favoris (Mon univers Ka)"; | |
| 121 | + return ( | |
| 122 | + <button | |
| 123 | + type="button" | |
| 124 | + className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`} | |
| 125 | + aria-label={label} | |
| 126 | + aria-pressed={on} | |
| 127 | + title={label} | |
| 128 | + onClick={(e) => { | |
| 129 | + e.preventDefault(); // la carte entière est un lien | |
| 130 | + e.stopPropagation(); | |
| 131 | + toggle(jobFavItem(job)); | |
| 132 | + }} | |
| 133 | + > | |
| 134 | + <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| 135 | + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> | |
| 136 | + </svg> | |
| 137 | + </button> | |
| 138 | + ); | |
| 139 | +} | |
added
frontend/src/pages/Favoris.tsx
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/Favoris.tsx | |
| 7 | + * Rôle : Mes favoris ♥ « Mon univers Ka » — offres mises en favoris, | |
| 8 | + * lues au hub Groupe KA (magasin central, aucun stockage local). | |
| 9 | + * Créé : 2026-08-23 Modifié : 2026-08-23 | |
| 10 | + * ============================================================================= | |
| 11 | + */ | |
| 12 | +import { Link } from "react-router-dom"; | |
| 13 | +import { FavItem, goLogin, useFav } from "../favorites"; | |
| 14 | + | |
| 15 | +/** /emploi/<uid> local si l'url du favori pointe vers job-ka.com. */ | |
| 16 | +function localPath(f: FavItem): string | null { | |
| 17 | + if (!f.url) return `/emploi/${encodeURIComponent(f.item_id)}`; | |
| 18 | + try { | |
| 19 | + const u = new URL(f.url); | |
| 20 | + if (u.hostname.endsWith("job-ka.com")) return u.pathname + u.search; | |
| 21 | + } catch { /* url invalide : lien externe ignoré */ } | |
| 22 | + return null; | |
| 23 | +} | |
| 24 | + | |
| 25 | +function FavCard({ f }: { f: FavItem }) { | |
| 26 | + const { toggle } = useFav(); | |
| 27 | + const path = localPath(f); | |
| 28 | + const inner = ( | |
| 29 | + <> | |
| 30 | + <h3>{f.title}</h3> | |
| 31 | + {f.subtitle && <span className="employer">{f.subtitle}</span>} | |
| 32 | + <div className="meta"> | |
| 33 | + {f.price_label && <span className="badge salary">{f.price_label}</span>} | |
| 34 | + </div> | |
| 35 | + <button | |
| 36 | + type="button" | |
| 37 | + className="fav-btn on" | |
| 38 | + aria-label="Retirer de mes favoris (Mon univers Ka)" | |
| 39 | + title="Retirer de mes favoris (Mon univers Ka)" | |
| 40 | + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggle(f); }} | |
| 41 | + > | |
| 42 | + <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| 43 | + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> | |
| 44 | + </svg> | |
| 45 | + </button> | |
| 46 | + </> | |
| 47 | + ); | |
| 48 | + return path | |
| 49 | + ? <Link to={path} className="job-card fav-card">{inner}</Link> | |
| 50 | + : <a href={f.url} className="job-card fav-card" target="_blank" rel="noopener noreferrer">{inner}</a>; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export default function FavorisPage() { | |
| 54 | + const { ready, connected, items } = useFav(); | |
| 55 | + | |
| 56 | + return ( | |
| 57 | + <main className="container"> | |
| 58 | + <h1>Mes favoris</h1> | |
| 59 | + <p className="muted"> | |
| 60 | + Vos offres d'emploi mises en favoris ♥ — synchronisées avec votre | |
| 61 | + compte <b>Mon univers Ka</b> sur toutes les plateformes du Groupe KA. | |
| 62 | + </p> | |
| 63 | + {!ready && <div className="empty">Chargement…</div>} | |
| 64 | + {ready && !connected && ( | |
| 65 | + <div className="empty"> | |
| 66 | + <p>Connectez-vous avec votre KA ID pour retrouver vos favoris.</p> | |
| 67 | + <button type="button" className="btn" onClick={goLogin}> | |
| 68 | + Se connecter avec KA ID | |
| 69 | + </button> | |
| 70 | + </div> | |
| 71 | + )} | |
| 72 | + {ready && connected && items.length === 0 && ( | |
| 73 | + <div className="empty"> | |
| 74 | + <p>Aucun favori pour l'instant — touchez le cœur ♥ sur une offre | |
| 75 | + pour la retrouver ici.</p> | |
| 76 | + <Link className="btn" to="/">Parcourir les offres</Link> | |
| 77 | + </div> | |
| 78 | + )} | |
| 79 | + {ready && connected && items.length > 0 && ( | |
| 80 | + <div className="job-list"> | |
| 81 | + {items.map((f) => <FavCard key={f.item_id} f={f} />)} | |
| 82 | + </div> | |
| 83 | + )} | |
| 84 | + {ready && connected && ( | |
| 85 | + <p className="muted" style={{ marginTop: 24 }}> | |
| 86 | + Tous vos favoris Groupe KA (logements, propriétés, produits…) :{" "} | |
| 87 | + <a href="https://www.groupe-ka.com/compte" target="_blank" rel="noopener noreferrer"> | |
| 88 | + Mon univers Ka ↗ | |
| 89 | + </a> | |
| 90 | + </p> | |
| 91 | + )} | |
| 92 | + </main> | |
| 93 | + ); | |
| 94 | +} | |
modified
frontend/src/pages/Job.tsx
+6 −3
@@ -4,13 +4,15 @@ | ||
| 4 | 4 | * Auteur : Simon-Pierre Boucher |
| 5 | 5 | * Contact : contact@spboucher.ai |
| 6 | 6 | * Fichier : frontend/src/pages/Job.tsx |
| 7 | − * Rôle : Fiche d'une offre — faits, description, lien vers l'offre originale | |
| 8 | − * Créé : 2026-08-17 Modifié : 2026-08-22 | |
| 7 | + * Rôle : Fiche d'une offre — faits, description, lien vers l'offre | |
| 8 | + * originale, cœur ♥ favoris « Mon univers Ka » | |
| 9 | + * Créé : 2026-08-17 Modifié : 2026-08-23 | |
| 9 | 10 | * ============================================================================= |
| 10 | 11 | */ |
| 11 | 12 | import { useEffect, useState } from "react"; |
| 12 | 13 | import { Link, useParams } from "react-router-dom"; |
| 13 | 14 | import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api"; |
| 15 | +import { FavButton } from "../favorites"; | |
| 14 | 16 | |
| 15 | 17 | export default function JobPage() { |
| 16 | 18 | const { uid = "" } = useParams(); |
@@ -44,7 +46,8 @@ export default function JobPage() { | ||
| 44 | 46 | onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} |
| 45 | 47 | /> |
| 46 | 48 | )} |
| 47 | − <h1 style={{ margin: 0 }}>{displayTitle(job)}</h1> | |
| 49 | + <h1 style={{ margin: 0, flex: 1 }}>{displayTitle(job)}</h1> | |
| 50 | + <FavButton job={job} big /> | |
| 48 | 51 | </div> |
| 49 | 52 | <p style={{ margin: "6px 0 0" }}> |
| 50 | 53 | <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> |
modified
frontend/src/styles.css
+21 −0
@@ -352,3 +352,24 @@ table.data tr:last-child td { border-bottom: none; } | ||
| 352 | 352 | .bar-row { grid-template-columns: 120px 1fr 44px; } |
| 353 | 353 | .sheet { padding: 20px; } |
| 354 | 354 | } |
| 355 | + | |
| 356 | +/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */ | |
| 357 | +.job-card { position: relative; } | |
| 358 | +.job-card h3 { padding-right: 48px; } | |
| 359 | +.fav-btn { | |
| 360 | + position: absolute; right: 14px; top: 14px; z-index: 2; | |
| 361 | + width: 40px; height: 40px; padding: 0; | |
| 362 | + display: inline-flex; align-items: center; justify-content: center; | |
| 363 | + border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer; | |
| 364 | + background: rgba(255, 255, 255, 0.95); color: var(--ink); | |
| 365 | + transition: transform 0.12s ease, background 0.12s ease; | |
| 366 | +} | |
| 367 | +.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; } | |
| 368 | +.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); } | |
| 369 | +.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); } | |
| 370 | +.fav-btn.on svg { fill: currentColor; } | |
| 371 | +/* fiche d'offre : le cœur à droite du titre */ | |
| 372 | +.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; } | |
| 373 | +.fav-btn.big svg { width: 21px; height: 21px; } | |
| 374 | +/* page /favoris */ | |
| 375 | +.fav-card { padding-right: 64px; } | |
modified
jobka/auth.py
+13 −5
@@ -140,11 +140,16 @@ def auth_config(): | ||
| 140 | 140 | |
| 141 | 141 | |
| 142 | 142 | @router.get("/auth/ka/login") |
| 143 | −def ka_login(request: Request): | |
| 144 | − """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" | |
| 143 | +def ka_login(request: Request, next: str = ""): | |
| 144 | + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com). | |
| 145 | + `next` (chemin local seulement) = page de retour après connexion — | |
| 146 | + transporté dans le state signé, donc infalsifiable.""" | |
| 145 | 147 | if not _ka_secret(): |
| 146 | 148 | raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") |
| 147 | − state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) | |
| 149 | + # anti open-redirect : uniquement un chemin local ("/…", pas "//…") | |
| 150 | + nxt = next if next.startswith("/") and not next.startswith("//") else "" | |
| 151 | + state = _sign({"n": secrets.token_urlsafe(12), "next": nxt, | |
| 152 | + "exp": time.time() + 600}) | |
| 148 | 153 | params = { |
| 149 | 154 | "client_id": CLIENT_ID, |
| 150 | 155 | "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", |
@@ -157,7 +162,8 @@ def ka_login(request: Request): | ||
| 157 | 162 | def ka_callback(request: Request, ka_token: str = "", state: str = ""): |
| 158 | 163 | """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local, |
| 159 | 164 | pose le cookie de session Job·Ka.""" |
| 160 | − if not ka_token or _verify(state) is None: | |
| 165 | + st = _verify(state) if ka_token else None | |
| 166 | + if st is None: | |
| 161 | 167 | raise HTTPException(400, "state invalide ou expiré") |
| 162 | 168 | claims = _verify_ka_token(ka_token) |
| 163 | 169 | if claims is None: |
@@ -197,7 +203,9 @@ def ka_callback(request: Request, ka_token: str = "", state: str = ""): | ||
| 197 | 203 | "picture": picture, |
| 198 | 204 | "exp": time.time() + SESSION_DAYS * 86400, |
| 199 | 205 | }) |
| 200 | − resp = RedirectResponse("/?login=ok") | |
| 206 | + nxt = st.get("next") or "" | |
| 207 | + resp = RedirectResponse(nxt if nxt.startswith("/") and not nxt.startswith("//") | |
| 208 | + else "/?login=ok") | |
| 201 | 209 | resp.set_cookie( |
| 202 | 210 | COOKIE, session, |
| 203 | 211 | max_age=SESSION_DAYS * 86400, |
added
jobka/hubfav.py
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/hubfav.py | |
| 6 | +# Rôle : Favoris ♥ « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) | |
| 7 | +# est le MAGASIN CENTRAL des favoris du groupe : Job·Ka ne stocke rien | |
| 8 | +# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un | |
| 9 | +# échec remonte à l'appelant) et la liste est lue au hub (GET signé, cache | |
| 10 | +# mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO. | |
| 11 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel). | |
| 12 | +# Créé : 2026-08-23 Modifié : 2026-08-23 | |
| 13 | +# ============================================================================= | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import hashlib | |
| 17 | +import hmac | |
| 18 | +import os | |
| 19 | +import threading | |
| 20 | +import time | |
| 21 | + | |
| 22 | +import requests | |
| 23 | + | |
| 24 | +CLIENT_ID = "job-ka" | |
| 25 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 26 | +CACHE_TTL = 30 # secondes — liste des favoris | |
| 27 | +TIMEOUT = 6 # secondes | |
| 28 | + | |
| 29 | +_cache: dict[str, tuple[float, list[dict]]] = {} | |
| 30 | +_lock = threading.Lock() | |
| 31 | + | |
| 32 | +# champs d'item acceptés -> longueur maximale (troncature défensive) | |
| 33 | +_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200, | |
| 34 | + "price_label": 60, "image_url": 500, "url": 500} | |
| 35 | + | |
| 36 | + | |
| 37 | +def _sig(ka_id: str, ts: int) -> str | None: | |
| 38 | + """Signature HMAC-SHA256 du hub : hex("job-ka.<ka_id>.<ts>").""" | |
| 39 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 40 | + if not secret: | |
| 41 | + return None | |
| 42 | + return hmac.new(secret.encode(), | |
| 43 | + f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 44 | + hashlib.sha256).hexdigest() | |
| 45 | + | |
| 46 | + | |
| 47 | +def linked(ka_id: str | None) -> bool: | |
| 48 | + """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe).""" | |
| 49 | + return bool(ka_id) and str(ka_id).startswith("ka-") | |
| 50 | + | |
| 51 | + | |
| 52 | +def clean_item(item: dict) -> dict: | |
| 53 | + """Ne garde que les champs d'item connus, en chaînes tronquées.""" | |
| 54 | + return {k: str(item.get(k) or "")[:n] | |
| 55 | + for k, n in _FIELDS.items() if item.get(k)} | |
| 56 | + | |
| 57 | + | |
| 58 | +def hub_toggle(ka_id: str, action: str, item: dict) -> bool: | |
| 59 | + """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : | |
| 60 | + le hub est le magasin des favoris, l'échec doit remonter à l'appelant.""" | |
| 61 | + ts = int(time.time()) | |
| 62 | + sig = _sig(ka_id, ts) | |
| 63 | + if not sig or not linked(ka_id) or action not in ("add", "remove"): | |
| 64 | + return False | |
| 65 | + try: | |
| 66 | + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 67 | + json={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 68 | + "ts": str(ts), "sig": sig, | |
| 69 | + "action": action, "item": item}) | |
| 70 | + ok = r.status_code == 200 | |
| 71 | + except Exception: | |
| 72 | + ok = False | |
| 73 | + if ok: | |
| 74 | + with _lock: | |
| 75 | + _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle | |
| 76 | + return ok | |
| 77 | + | |
| 78 | + | |
| 79 | +def hub_list(ka_id: str) -> list[dict] | None: | |
| 80 | + """Favoris Job·Ka du membre, lus au hub (cache mémoire 30 s). | |
| 81 | + [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache).""" | |
| 82 | + if not linked(ka_id): | |
| 83 | + return [] # compte legacy non relié au hub | |
| 84 | + now = time.time() | |
| 85 | + with _lock: | |
| 86 | + hit = _cache.get(ka_id) | |
| 87 | + if hit and now - hit[0] < CACHE_TTL: | |
| 88 | + return hit[1] | |
| 89 | + sig = _sig(ka_id, int(now)) | |
| 90 | + if not sig: | |
| 91 | + return None | |
| 92 | + try: | |
| 93 | + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 94 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 95 | + "ts": int(now), "sig": sig}) | |
| 96 | + if r.status_code != 200: | |
| 97 | + return None | |
| 98 | + favs = r.json().get("favorites") or [] | |
| 99 | + if not isinstance(favs, list): | |
| 100 | + return None | |
| 101 | + except Exception: | |
| 102 | + return None | |
| 103 | + favs = [f for f in favs if isinstance(f, dict)] | |
| 104 | + with _lock: | |
| 105 | + _cache[ka_id] = (now, favs) | |
| 106 | + return favs | |
modified
jobka/web.py
+36 −3
@@ -15,14 +15,14 @@ import threading | ||
| 15 | 15 | import time |
| 16 | 16 | from pathlib import Path |
| 17 | 17 | |
| 18 | −from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query | |
| 18 | +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request | |
| 19 | 19 | from fastapi.middleware.cors import CORSMiddleware |
| 20 | 20 | from fastapi.middleware.gzip import GZipMiddleware |
| 21 | 21 | from fastapi.responses import FileResponse, Response |
| 22 | 22 | from fastapi.staticfiles import StaticFiles |
| 23 | 23 | from pydantic import BaseModel, Field |
| 24 | 24 | |
| 25 | −from . import auth, db, ingest | |
| 25 | +from . import auth, db, hubfav, ingest | |
| 26 | 26 | from .dedup import AGGREGATORS |
| 27 | 27 | from .schema import JobPosting |
| 28 | 28 | |
@@ -529,6 +529,39 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None): | ||
| 529 | 529 | return {"status": "démarré", "source": source or "toutes"} |
| 530 | 530 | |
| 531 | 531 | |
| 532 | +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------ | |
| 533 | +@app.get("/api/favorites") | |
| 534 | +def favorites(request: Request): | |
| 535 | + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).""" | |
| 536 | + user = auth.current_user(request) | |
| 537 | + if not user: | |
| 538 | + raise HTTPException(401, "Connexion KA ID requise") | |
| 539 | + items = hubfav.hub_list(user.get("ka_id") or "") | |
| 540 | + if items is None: | |
| 541 | + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") | |
| 542 | + return {"ids": [i["item_id"] for i in items if i.get("item_id")], | |
| 543 | + "items": items} | |
| 544 | + | |
| 545 | + | |
| 546 | +@app.post("/api/favorites/toggle") | |
| 547 | +def toggle_favorite(request: Request, body: dict = Body(...)): | |
| 548 | + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub | |
| 549 | + Groupe KA de façon synchrone : le hub est la seule source de vérité.""" | |
| 550 | + user = auth.current_user(request) | |
| 551 | + if not user: | |
| 552 | + raise HTTPException(401, "Connexion KA ID requise") | |
| 553 | + ka_id = user.get("ka_id") or "" | |
| 554 | + if not hubfav.linked(ka_id): | |
| 555 | + raise HTTPException(403, "Compte non relié au hub Groupe KA") | |
| 556 | + on = bool(body.get("on")) | |
| 557 | + item = hubfav.clean_item(body.get("item") or {}) | |
| 558 | + if not item.get("item_id"): | |
| 559 | + raise HTTPException(422, "item.item_id requis") | |
| 560 | + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): | |
| 561 | + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") | |
| 562 | + return {"ok": True, "on": on} | |
| 563 | + | |
| 564 | + | |
| 532 | 565 | # --- Frontend (build Vite) --------------------------------------------------- |
| 533 | 566 | if FRONTEND_DIST.exists(): |
| 534 | 567 | app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") |
@@ -554,7 +587,7 @@ if FRONTEND_DIST.exists(): | ||
| 554 | 587 | # routes rendues uniquement côté client — tout autre chemin inconnu renvoie |
| 555 | 588 | # index.html avec un statut 404 (pas de soft-404 pour les moteurs) |
| 556 | 589 | _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs", |
| 557 | − "confidentialite", "conditions"} | |
| 590 | + "favoris", "confidentialite", "conditions"} | |
| 558 | 591 | _CLIENT_PREFIXES = ("emploi/",) |
| 559 | 592 | |
| 560 | 593 | @app.get("/{full_path:path}") |
| 561 | 594 | |