SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Profil enrichi — photo téléversée, bio, ville, coordonnées, réseaux sociaux

- louka/profile.py : PUT /api/me/profile (nom affiché, bio 280, ville,
  téléphone, site web, réseaux instagram/facebook/x/linkedin/tiktok/youtube),
  POST /api/me/avatar (Pillow : EXIF, recadrage carré centré, 512 px, JPEG,
  purge des anciennes photos), DELETE /api/me/avatar (retour avatar Google)
- /uploads/ monté (data/uploads/), colonnes users (display_name, avatar_url,
  bio, city, phone, website, socials JSON)
- /api/me : nom et avatar « effectifs » (display_name > Google, upload >
  Google) + tous les champs ; profil public /u/{ka_id} : bio, ville, rôle,
  site, réseaux (jamais courriel/téléphone)
- /profil : éditeur complet (photo avec téléversement, nom affiché, bio avec
  compteur, ville, téléphone, site, 6 réseaux avec icônes maison)
- /u/{ka_id} : bio, chips ville/rôle/site, boutons réseaux sociaux
- python-multipart ajouté (formulaires multipart FastAPI)
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 3cebcd6

14 changed files +679 −36

modified frontend/src/App.tsx +20 −8
@@ -81,19 +81,31 @@ function AccountMenu() {
81 81 const [menuOpen, setMenuOpen] = useState(false);
82 82
83 83 useEffect(() => {
84 − fetchAuthConfig().then((c) => setEnabled(c.google)).catch(() => {});
84 + fetchAuthConfig().then((c) => setEnabled(c.ka || c.google)).catch(() => {});
85 85 }, []);
86 86
87 87 if (!enabled) return null;
88 88 if (!me) {
89 89 return (
90 − <a className="login-btn" href="/api/auth/google/login">
91 − <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
92 − <path fill="#4285F4" d="M23.5 12.3c0-.9-.1-1.5-.2-2.2H12v4.2h6.5c-.1 1.1-.8 2.7-2.4 3.8l3.7 2.9c2.3-2.1 3.7-5.1 3.7-8.7z"/>
93 − <path fill="#34A853" d="M12 24c3.2 0 6-1.1 7.9-2.9l-3.7-2.9c-1 .7-2.4 1.2-4.2 1.2-3.2 0-6-2.1-7-5.1L1.2 17.2C3.2 21.2 7.3 24 12 24z"/>
94 − <path fill="#FBBC05" d="M5 14.2c-.2-.7-.4-1.4-.4-2.2s.1-1.5.4-2.2L1.2 6.8C.4 8.4 0 10.1 0 12s.4 3.6 1.2 5.2z"/>
95 − <path fill="#EA4335" d="M12 4.6c1.8 0 3 .8 3.7 1.4l3.3-3.2C17 1.1 15.2 0 12 0 7.3 0 3.2 2.8 1.2 6.8L5 9.8c1-3 3.8-5.2 7-5.2z"/>
96 − </svg>
90 + <a className="login-btn" href="/api/auth/ka/login">
91 + <span
92 + aria-hidden="true"
93 + style={{
94 + display: "inline-flex",
95 + alignItems: "center",
96 + justifyContent: "center",
97 + width: 18,
98 + height: 16,
99 + borderRadius: 4,
100 + background: "#141814",
101 + color: "#d9f26b",
102 + font: "700 9px/1 'Space Grotesk', system-ui, sans-serif",
103 + letterSpacing: "-0.02em",
104 + transform: "rotate(-2deg)",
105 + }}
106 + >
107 + KA
108 + </span>
97 109 Connexion
98 110 </a>
99 111 );
modified frontend/src/api.ts +38 −3
@@ -246,12 +246,24 @@ export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
246 246 export const fetchStats = () => get<Stats>("/api/stats");
247 247
248 248 // -- compte utilisateur (connexion Google) ------------------------------------
249 +export type Socials = Partial<Record<
250 + "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string>>;
251 +
249 252 export interface Me {
250 253 uid: number;
251 254 ka_id: string; // identifiant membre « ka-0123456789 » (écosystème Groupe KA)
252 255 email: string;
253 − name: string;
254 − picture: string;
256 + name: string; // nom effectif (display_name sinon nom Google)
257 + google_name: string;
258 + picture: string; // avatar effectif (photo téléversée sinon Google)
259 + google_picture: string;
260 + avatar_url: string | null; // photo téléversée (« /uploads/avatars/… »)
261 + display_name: string;
262 + bio: string;
263 + city: string;
264 + phone: string;
265 + website: string;
266 + socials: Socials;
255 267 public: boolean; // profil public /u/{ka_id} activé (opt-in)
256 268 role: "locataire" | "gestionnaire" | null;
257 269 org_source: string | null; // source réclamée (gestionnaire)
@@ -263,6 +275,24 @@ export interface Me {
263 275 export const setRole = (role: "locataire" | "gestionnaire") =>
264 276 fetch(`/api/me/role?role=${role}`, { method: "POST" });
265 277
278 +export const updateMyProfile = (fields: {
279 + display_name: string; bio: string; city: string;
280 + phone: string; website: string; socials: Socials;
281 +}) => fetch("/api/me/profile", {
282 + method: "PUT",
283 + headers: { "Content-Type": "application/json" },
284 + body: JSON.stringify(fields),
285 +});
286 +
287 +export async function uploadAvatar(file: File): Promise<string | null> {
288 + const fd = new FormData();
289 + fd.append("file", file);
290 + const res = await fetch("/api/me/avatar", { method: "POST", body: fd });
291 + if (!res.ok) return null;
292 + return ((await res.json()) as { avatar_url: string }).avatar_url;
293 +}
294 +export const deleteAvatar = () => fetch("/api/me/avatar", { method: "DELETE" });
295 +
266 296 // -- favoris (locataire) -------------------------------------------------------
267 297 export const fetchFavorites = () =>
268 298 get<{ uids: string[]; listings: Listing[] }>("/api/favorites");
@@ -304,6 +334,11 @@ export interface PublicProfile {
304 334 ka_id: string;
305 335 name: string;
306 336 picture: string;
337 + bio: string;
338 + city: string;
339 + website: string;
340 + socials: Socials;
341 + role: "locataire" | "gestionnaire" | null;
307 342 created_at: number | null;
308 343 }
309 344 export const fetchPublicProfile = (kaId: string) =>
@@ -319,7 +354,7 @@ export async function fetchMe(): Promise<Me | null> {
319 354 return null;
320 355 }
321 356 }
322 −export const fetchAuthConfig = () => get<{ google: boolean }>("/api/auth/config");
357 +export const fetchAuthConfig = () => get<{ google: boolean; ka: boolean }>("/api/auth/config");
323 358 export const logout = () => fetch("/api/auth/logout", { method: "POST" });
324 359
325 360 export const fmtPrice = (p: number | null, label?: string) =>
modified frontend/src/components/Icons.tsx +44 −0
@@ -161,6 +161,50 @@ export const IcoHeart = (p: P & { filled?: boolean }) => {
161 161 );
162 162 };
163 163
164 +// -- réseaux sociaux (tracés au trait, même langage que le reste) --------------
165 +
166 +export const IcoInstagram = (p: P) => (
167 + <Base {...p}>
168 + <rect x="4" y="4" width="16" height="16" rx="4.5" />
169 + <circle cx="12" cy="12" r="3.6" />
170 + <path d="M16.6 7.4v.1" strokeWidth="2.6" />
171 + </Base>
172 +);
173 +
174 +export const IcoFacebook = (p: P) => (
175 + <Base {...p}>
176 + <path d="M14.8 4h-2.3a3.4 3.4 0 0 0-3.4 3.4V10H6.8v3.4h2.3V21h3.4v-7.6h2.6l.6-3.4h-3.2V7.8c0-.5.4-.9.9-.9h2.4z" />
177 + </Base>
178 +);
179 +
180 +export const IcoX = (p: P) => (
181 + <Base {...p}>
182 + <path d="M4.5 4.5 19.5 19.5M19 4.5 5 19.5" />
183 + </Base>
184 +);
185 +
186 +export const IcoLinkedIn = (p: P) => (
187 + <Base {...p}>
188 + <rect x="4" y="4" width="16" height="16" rx="2.5" />
189 + <path d="M8.2 10.5V16M8.2 8v.1" strokeWidth="2.2" />
190 + <path d="M11.8 16v-3.2a2 2 0 0 1 4 0V16" strokeWidth="2.2" />
191 + </Base>
192 +);
193 +
194 +export const IcoTikTok = (p: P) => (
195 + <Base {...p}>
196 + <path d="M13.5 4v10.3a3.3 3.3 0 1 1-3.3-3.3" />
197 + <path d="M13.5 6.2c.8 2 2.6 3.4 4.8 3.6" />
198 + </Base>
199 +);
200 +
201 +export const IcoYouTube = (p: P) => (
202 + <Base {...p}>
203 + <rect x="3" y="6" width="18" height="12" rx="3.5" />
204 + <path d="m10.3 9.5 4.6 2.5-4.6 2.5z" />
205 + </Base>
206 +);
207 +
164 208 export const IcoChevronLeft = (p: P) => (
165 209 <Base {...p}>
166 210 <path d="m14.5 5-7 7 7 7" />
modified frontend/src/pages/Bienvenue.tsx +2 −2
@@ -28,8 +28,8 @@ export default function BienvenuePage() {
28 28 <div className="container profil">
29 29 <span className="kicker">Bienvenue</span>
30 30 <h1>Créez d'abord votre <span className="hl">compte</span>.</h1>
31 − <a className="btn btn-primary" href="/api/auth/google/login">
32 − Se connecter avec Google
31 + <a className="btn btn-primary" href="/api/auth/ka/login">
32 + Se connecter avec KA ID
33 33 </a>
34 34 </div>
35 35 );
modified frontend/src/pages/Favoris.tsx +2 −2
@@ -23,8 +23,8 @@ export default function FavorisPage() {
23 23 <div className="container profil">
24 24 <span className="kicker">Mes favoris</span>
25 25 <h1>Connectez-vous pour garder vos <span className="hl">coups de cœur</span>.</h1>
26 − <a className="btn btn-primary" href="/api/auth/google/login">
27 − Se connecter avec Google
26 + <a className="btn btn-primary" href="/api/auth/ka/login">
27 + Se connecter avec KA ID
28 28 </a>
29 29 </div>
30 30 );
modified frontend/src/pages/Gestion.tsx +2 −2
@@ -61,8 +61,8 @@ export default function GestionPage() {
61 61 <div className="container profil">
62 62 <span className="kicker">Espace gestionnaire</span>
63 63 <h1>Connectez-vous pour gérer <span className="hl">votre page</span>.</h1>
64 − <a className="btn btn-primary" href="/api/auth/google/login">
65 − Se connecter avec Google
64 + <a className="btn btn-primary" href="/api/auth/ka/login">
65 + Se connecter avec KA ID
66 66 </a>
67 67 </div>
68 68 );
modified frontend/src/pages/Profile.tsx +178 −10
@@ -4,10 +4,16 @@
4 4 // pages/Profile.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID),
5 5 // informations du compte, actions (déconnexion, témoins, légal)
6 6 // -----------------------------------------------------------------------------
7 −import { useEffect, useState } from "react";
7 +import { useRef, useEffect, useState } from "react";
8 8 import { Link, useNavigate } from "react-router-dom";
9 −import { Me, fetchMe, logout, setPublicProfile } from "../api";
10 −import { IcoBuilding, IcoDoc, IcoHeart, IcoLock } from "../components/Icons";
9 +import {
10 + Me, Socials, deleteAvatar, fetchMe, logout, setPublicProfile,
11 + updateMyProfile, uploadAvatar,
12 +} from "../api";
13 +import {
14 + IcoBuilding, IcoCamera, IcoDoc, IcoFacebook, IcoHeart, IcoInstagram,
15 + IcoLinkedIn, IcoLock, IcoTikTok, IcoX, IcoYouTube,
16 +} from "../components/Icons";
11 17
12 18 const fmtEpoch = (ts: number | null | undefined): string => {
13 19 if (!ts) return "—";
@@ -16,6 +22,155 @@ const fmtEpoch = (ts: number | null | undefined): string => {
16 22 }).replace(/^1 /, "1ᵉʳ ");
17 23 };
18 24
25 +const SOCIAL_FIELDS: { key: keyof Socials; label: string; icon: JSX.Element;
26 + placeholder: string }[] = [
27 + { key: "instagram", label: "Instagram", icon: <IcoInstagram size={15} />, placeholder: "@pseudo ou URL" },
28 + { key: "facebook", label: "Facebook", icon: <IcoFacebook size={15} />, placeholder: "profil ou URL" },
29 + { key: "x", label: "X (Twitter)", icon: <IcoX size={15} />, placeholder: "@pseudo ou URL" },
30 + { key: "linkedin", label: "LinkedIn", icon: <IcoLinkedIn size={15} />, placeholder: "profil ou URL" },
31 + { key: "tiktok", label: "TikTok", icon: <IcoTikTok size={15} />, placeholder: "@pseudo ou URL" },
32 + { key: "youtube", label: "YouTube", icon: <IcoYouTube size={15} />, placeholder: "chaîne ou URL" },
33 +];
34 +
35 +/** Section d'édition : photo, nom affiché, bio, coordonnées, réseaux sociaux */
36 +function ProfileEditor({ me, onSaved }: { me: Me; onSaved: () => void }) {
37 + const [form, setForm] = useState({
38 + display_name: me.display_name || "",
39 + bio: me.bio || "",
40 + city: me.city || "",
41 + phone: me.phone || "",
42 + website: me.website || "",
43 + socials: { ...me.socials } as Socials,
44 + });
45 + const [saving, setSaving] = useState(false);
46 + const [saved, setSaved] = useState(false);
47 + const [error, setError] = useState("");
48 + const [avatarBusy, setAvatarBusy] = useState(false);
49 + const fileRef = useRef<HTMLInputElement | null>(null);
50 +
51 + const save = async () => {
52 + setSaving(true);
53 + setError("");
54 + const res = await updateMyProfile(form);
55 + setSaving(false);
56 + if (!res.ok) { setError("Enregistrement impossible — réessayez."); return; }
57 + setSaved(true);
58 + setTimeout(() => setSaved(false), 2000);
59 + onSaved();
60 + };
61 +
62 + const pickPhoto = async (f: File | undefined) => {
63 + if (!f) return;
64 + setAvatarBusy(true);
65 + setError("");
66 + const url = await uploadAvatar(f);
67 + setAvatarBusy(false);
68 + if (!url) { setError("Photo refusée — image JPG/PNG/WebP de 8 Mo max."); return; }
69 + onSaved();
70 + };
71 +
72 + return (
73 + <section className="org-form profil-edit">
74 + <h3>Personnaliser mon profil</h3>
75 +
76 + {/* — photo — */}
77 + <div className="pe-avatar-row">
78 + {me.picture
79 + ? <img className="pe-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
80 + : <span className="pe-avatar pe-avatar-empty">
81 + {(me.name || me.email).charAt(0).toUpperCase()}
82 + </span>}
83 + <div className="pe-avatar-actions">
84 + <button className="btn btn-ghost" disabled={avatarBusy}
85 + onClick={() => fileRef.current?.click()}>
86 + <IcoCamera size={14} /> {avatarBusy ? "Téléversement…" : "Changer la photo"}
87 + </button>
88 + {me.avatar_url && (
89 + <button className="btn btn-ghost" disabled={avatarBusy}
90 + onClick={async () => { await deleteAvatar(); onSaved(); }}>
91 + Retirer (revenir à Google)
92 + </button>
93 + )}
94 + <span className="pe-hint">JPG, PNG ou WebP — recadrée en carré 512 px.</span>
95 + </div>
96 + <input
97 + ref={fileRef} type="file" accept="image/*" hidden
98 + onChange={(e) => pickPhoto(e.target.files?.[0])}
99 + />
100 + </div>
101 +
102 + <div className="org-form-row">
103 + <label>
104 + <span>Nom affiché</span>
105 + <input
106 + value={form.display_name} maxLength={80}
107 + placeholder={me.google_name || "Votre nom"}
108 + onChange={(e) => setForm({ ...form, display_name: e.target.value })}
109 + />
110 + </label>
111 + <label>
112 + <span>Ville</span>
113 + <input
114 + value={form.city} maxLength={60} placeholder="Québec, Montréal…"
115 + onChange={(e) => setForm({ ...form, city: e.target.value })}
116 + />
117 + </label>
118 + </div>
119 +
120 + <label>
121 + <span>Bio ({280 - form.bio.length} caractères restants)</span>
122 + <textarea
123 + value={form.bio} rows={3} maxLength={280}
124 + placeholder="Présentez-vous en quelques mots…"
125 + onChange={(e) => setForm({ ...form, bio: e.target.value })}
126 + />
127 + </label>
128 +
129 + <div className="org-form-row">
130 + <label>
131 + <span>Téléphone</span>
132 + <input
133 + value={form.phone} maxLength={40} placeholder="418 555-0123"
134 + onChange={(e) => setForm({ ...form, phone: e.target.value })}
135 + />
136 + </label>
137 + <label>
138 + <span>Site web</span>
139 + <input
140 + value={form.website} maxLength={300} placeholder="https://…"
141 + onChange={(e) => setForm({ ...form, website: e.target.value })}
142 + />
143 + </label>
144 + </div>
145 +
146 + <h4 className="pe-socials-title">Réseaux sociaux</h4>
147 + <div className="pe-socials">
148 + {SOCIAL_FIELDS.map((s) => (
149 + <label key={s.key} className="pe-social">
150 + <span className="pe-social-ico" title={s.label}>{s.icon}</span>
151 + <input
152 + value={form.socials[s.key] ?? ""} maxLength={200}
153 + placeholder={`${s.label} — ${s.placeholder}`}
154 + onChange={(e) => setForm({
155 + ...form,
156 + socials: { ...form.socials, [s.key]: e.target.value },
157 + })}
158 + />
159 + </label>
160 + ))}
161 + </div>
162 +
163 + {error && <p className="claim-error">{error}</p>}
164 + <button
165 + className={`btn btn-primary ${saved ? "pc-copy ok" : ""}`}
166 + disabled={saving} onClick={save}
167 + >
168 + {saved ? "✓ Enregistré" : saving ? "Enregistrement…" : "Enregistrer mon profil"}
169 + </button>
170 + </section>
171 + );
172 +}
173 +
19 174 export default function ProfilePage() {
20 175 const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement
21 176 const [copied, setCopied] = useState(false);
@@ -53,8 +208,8 @@ export default function ProfilePage() {
53 208 identifiant de membre <b>KA-ID</b>, valide dans tout l'écosystème
54 209 Groupe KA.
55 210 </p>
56 − <a className="btn btn-primary" href="/api/auth/google/login">
57 − Se connecter avec Google
211 + <a className="btn btn-primary" href="/api/auth/ka/login">
212 + Se connecter avec KA ID
58 213 </a>
59 214 </div>
60 215 );
@@ -113,8 +268,16 @@ export default function ProfilePage() {
113 268 </div>
114 269 <div className="pg-item">
115 270 <span className="pg-label">Connexion</span>
116 − <span className="pg-value">Compte Google</span>
271 + <span className="pg-value">
272 + {me.provider === "ka-id" ? "KA ID (groupe-ka.com)" : "Compte Google"}
273 + </span>
117 274 </div>
275 + {me.city && (
276 + <div className="pg-item">
277 + <span className="pg-label">Ville</span>
278 + <span className="pg-value">{me.city}</span>
279 + </div>
280 + )}
118 281 <div className="pg-item">
119 282 <span className="pg-label">Profil</span>
120 283 <span className="pg-value">
@@ -133,14 +296,19 @@ export default function ProfilePage() {
133 296 </div>
134 297 </section>
135 298
299 + <ProfileEditor
300 + me={me}
301 + onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })}
302 + />
303 +
136 304 <p className="profil-note">
137 305 Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}
138 306 <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
139 307 Groupe KA
140 308 </a>{" "}
141 309 — il vous suit sur toutes les plateformes du groupe. Lou-Ka ne conserve
142 − que votre nom, votre courriel et votre avatar Google ; rien d'autre,
143 − et jamais revendus.
310 + que les informations de ce profil (et ce que vous choisissez d'y
311 + ajouter) ; rien d'autre, et jamais revendus.
144 312 </p>
145 313
146 314 {/* ——— Profil public ——— */}
@@ -148,8 +316,8 @@ export default function ProfilePage() {
148 316 <h3>Profil public</h3>
149 317 <p>
150 318 Activez votre page publique pour partager votre carte de membre —
151 − elle affiche votre nom, votre avatar et votre ancienneté,{" "}
152 − <b>jamais votre courriel</b>.
319 + elle affiche votre nom, votre photo, votre bio, votre ville et vos
320 + réseaux sociaux, <b>jamais votre courriel ni votre téléphone</b>.
153 321 </p>
154 322 <div className="seg" role="group" aria-label="Profil public">
155 323 {[[false, "Privé"], [true, "Public"]].map(([v, l]) => (
modified frontend/src/pages/PublicProfile.tsx +67 −3
@@ -7,8 +7,37 @@
7 7 // -----------------------------------------------------------------------------
8 8 import { useEffect, useState } from "react";
9 9 import { Link, useParams } from "react-router-dom";
10 −import { PublicProfile, fetchPublicProfile } from "../api";
11 −import { IcoCompass } from "../components/Icons";
10 +import { PublicProfile, Socials, fetchPublicProfile } from "../api";
11 +import {
12 + IcoCompass, IcoFacebook, IcoInstagram, IcoLinkedIn, IcoTikTok, IcoX,
13 + IcoYouTube,
14 +} from "../components/Icons";
15 +
16 +const SOCIAL_META: { key: keyof Socials; label: string; icon: JSX.Element }[] = [
17 + { key: "instagram", label: "Instagram", icon: <IcoInstagram size={17} /> },
18 + { key: "facebook", label: "Facebook", icon: <IcoFacebook size={17} /> },
19 + { key: "x", label: "X (Twitter)", icon: <IcoX size={17} /> },
20 + { key: "linkedin", label: "LinkedIn", icon: <IcoLinkedIn size={17} /> },
21 + { key: "tiktok", label: "TikTok", icon: <IcoTikTok size={17} /> },
22 + { key: "youtube", label: "YouTube", icon: <IcoYouTube size={17} /> },
23 +];
24 +
25 +const SOCIAL_BASE: Record<string, string> = {
26 + instagram: "https://www.instagram.com/",
27 + facebook: "https://www.facebook.com/",
28 + x: "https://x.com/",
29 + linkedin: "https://www.linkedin.com/in/",
30 + tiktok: "https://www.tiktok.com/@",
31 + youtube: "https://www.youtube.com/@",
32 +};
33 +
34 +/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */
35 +function socialUrl(key: string, value: string): string {
36 + const v = value.trim();
37 + if (/^https?:\/\//i.test(v)) return v;
38 + if (key === "website") return `https://${v}`;
39 + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");
40 +}
12 41
13 42 const fmtEpoch = (ts: number | null | undefined): string => {
14 43 if (!ts) return "—";
@@ -71,6 +100,41 @@ export default function PublicProfilePage() {
71 100 </div>
72 101 </div>
73 102
103 + {(profile.bio || profile.city || profile.role) && (
104 + <section className="pub-about">
105 + {profile.bio && <p className="pub-bio">{profile.bio}</p>}
106 + <div className="pub-meta">
107 + {profile.city && <span className="stat-chip">{profile.city}</span>}
108 + {profile.role === "gestionnaire" && (
109 + <span className="stat-chip">Gestionnaire de logements</span>
110 + )}
111 + {profile.role === "locataire" && (
112 + <span className="stat-chip">À la recherche d'un logement</span>
113 + )}
114 + {profile.website && (
115 + <a className="stat-chip" href={socialUrl("website", profile.website)}
116 + target="_blank" rel="noopener noreferrer">Site web ↗</a>
117 + )}
118 + </div>
119 + </section>
120 + )}
121 +
122 + {Object.keys(profile.socials || {}).length > 0 && (
123 + <div className="pub-socials">
124 + {SOCIAL_META.filter((s) => profile.socials[s.key]).map((s) => (
125 + <a
126 + key={s.key}
127 + className="pub-social"
128 + href={socialUrl(s.key, profile.socials[s.key]!)}
129 + target="_blank" rel="noopener noreferrer"
130 + aria-label={s.label} title={s.label}
131 + >
132 + {s.icon}
133 + </a>
134 + ))}
135 + </div>
136 + )}
137 +
74 138 <p className="profil-note">
75 139 Membre de <b>Lou-Ka</b>, l'agrégateur de logements à louer du{" "}
76 140 <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
@@ -80,7 +144,7 @@ export default function PublicProfilePage() {
80 144
81 145 <div className="profil-actions">
82 146 <Link className="btn btn-primary" to="/">Explorer les logements</Link>
83 − <a className="btn btn-ghost" href="/api/auth/google/login">Créer mon compte</a>
147 + <a className="btn btn-ghost" href="/api/auth/ka/login">Créer mon compte</a>
84 148 </div>
85 149 </div>
86 150 );
modified frontend/src/styles.css +36 −0
@@ -1306,3 +1306,39 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1306 1306 .orgpub-desc { max-width: 720px; color: var(--ink-2); margin: 18px 0 6px; white-space: pre-line; }
1307 1307 .orgpub .results-head h2 { display: inline-flex; align-items: center; gap: 8px; }
1308 1308 @media (max-width: 560px) { .orgpub-head { flex-direction: column; } }
1309 +
1310 +/* ================= Éditeur de profil (photo, bio, réseaux) ================= */
1311 +.profil-edit { margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); }
1312 +.pe-avatar-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
1313 +.pe-avatar {
1314 + width: 84px; height: 84px; border-radius: 999px; object-fit: cover;
1315 + border: 2.5px solid var(--ink); box-shadow: 4px 4px 0 rgba(20,24,20,0.15);
1316 + display: flex; align-items: center; justify-content: center;
1317 +}
1318 +.pe-avatar-empty { background: var(--lime); font-family: var(--font-display); font-weight: 700; font-size: 34px; }
1319 +.pe-avatar-actions { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
1320 +.pe-avatar-actions .btn { display: inline-flex; align-items: center; gap: 7px; }
1321 +.pe-hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); }
1322 +.pe-socials-title { font-size: 15px; margin-top: 6px; }
1323 +.pe-socials { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
1324 +.pe-social { display: flex !important; flex-direction: row !important; align-items: center; gap: 0 !important; }
1325 +.pe-social-ico {
1326 + display: flex; align-items: center; justify-content: center;
1327 + width: 40px; align-self: stretch; flex: none;
1328 + background: var(--ink); color: var(--lime);
1329 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl) 0 0 var(--r-ctl);
1330 +}
1331 +.pe-social input { flex: 1; border-radius: 0 var(--r-ctl) var(--r-ctl) 0; border-left: 0; }
1332 +
1333 +/* profil public — à propos + réseaux */
1334 +.pub-about { max-width: 640px; margin-top: 22px; }
1335 +.pub-bio { font-size: 15.5px; color: var(--ink); margin: 0 0 12px; white-space: pre-line; }
1336 +.pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
1337 +.pub-socials { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
1338 +.pub-social {
1339 + display: flex; align-items: center; justify-content: center;
1340 + width: 44px; height: 44px; background: var(--surface);
1341 + border: 2px solid var(--ink); border-radius: 12px; color: var(--ink);
1342 + transition: all 0.12s ease;
1343 +}
1344 +.pub-social:hover { background: var(--ink); color: var(--lime); transform: translate(-2px, -2px); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
modified louka/auth.py +150 −6
@@ -107,13 +107,134 @@ def _ensure_ka_id(con, uid: int) -> str:
107 107 continue
108 108
109 109
110 +# -- KA ID (hub d'identité du groupe — groupe-ka.com) ---------------------------
111 +# « Se connecter avec KA » : Lou·Ka ne parle plus à Google directement ;
112 +# l'utilisateur est envoyé au hub (qui offre Google OU courriel/mot de
113 +# passe), puis revient ici avec un jeton signé (HS256, secret partagé
114 +# KA_SSO_SECRET) transportant son profil. Même KA ID sur tout le groupe.
115 +# Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel).
116 +
117 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
118 +
119 +
120 +def _ka_secret() -> bytes | None:
121 + s = os.environ.get("KA_SSO_SECRET")
122 + return s.encode() if s else None
123 +
124 +
125 +def _b64url(s: str) -> bytes:
126 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
127 +
128 +
129 +def _verify_ka_token(token: str) -> dict | None:
130 + """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement)."""
131 + secret = _ka_secret()
132 + if not secret:
133 + return None
134 + try:
135 + h_b64, p_b64, s_b64 = token.split(".")
136 + expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),
137 + hashlib.sha256).digest()
138 + if not hmac.compare_digest(_b64url(s_b64), expected):
139 + return None
140 + if json.loads(_b64url(h_b64)).get("alg") != "HS256":
141 + return None
142 + claims = json.loads(_b64url(p_b64))
143 + if claims.get("iss") != KA_HUB_URL:
144 + return None
145 + aud = claims.get("aud")
146 + if (aud if isinstance(aud, str) else "") != "lou-ka" \
147 + and "lou-ka" not in (aud if isinstance(aud, list) else []):
148 + return None
149 + if claims.get("exp", 0) < time.time():
150 + return None
151 + return claims
152 + except Exception:
153 + return None
154 +
155 +
156 +@router.get("/auth/ka/login")
157 +def ka_login(request: Request):
158 + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""
159 + if not _ka_secret():
160 + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
161 + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})
162 + params = {
163 + "client_id": "lou-ka",
164 + "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
165 + "state": state,
166 + }
167 + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")
168 +
169 +
170 +@router.get("/auth/ka/callback")
171 +def ka_callback(request: Request, ka_token: str = "", state: str = ""):
172 + """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,
173 + pose le cookie de session Lou·Ka."""
174 + if not ka_token or _verify(state) is None:
175 + raise HTTPException(400, "state invalide ou expiré")
176 + claims = _verify_ka_token(ka_token)
177 + if claims is None:
178 + raise HTTPException(401, "jeton KA invalide ou expiré")
179 +
180 + email = (claims.get("email") or "").lower()
181 + name = claims.get("name") or (email.split("@")[0] if email else "membre")
182 + picture = claims.get("picture") or ""
183 + hub_key = f"ka:{claims.get('sub')}" # clé stable côté hub (google_sub)
184 +
185 + con = db.connect()
186 + now = time.time()
187 + # 1) compte déjà lié au hub ; 2) sinon liaison par courriel (ex-compte
188 + # Google direct — on garde son google_sub d'origine) ; 3) création.
189 + row = con.execute("SELECT id, role FROM users WHERE google_sub=?",
190 + (hub_key,)).fetchone()
191 + if row is None and email:
192 + row = con.execute(
193 + "SELECT id, role FROM users WHERE email=? ORDER BY id LIMIT 1",
194 + (email,)).fetchone()
195 + if row is not None:
196 + con.execute(
197 + "UPDATE users SET email=?, name=?, picture=?, last_login=? WHERE id=?",
198 + (email, name, picture, now, row["id"]))
199 + uid, role = row["id"], row["role"]
200 + else:
201 + cur = con.execute(
202 + """INSERT INTO users (google_sub, email, name, picture,
203 + created_at, last_login)
204 + VALUES (?,?,?,?,?,?)""",
205 + (hub_key, email, name, picture, now, now))
206 + uid, role = cur.lastrowid, None
207 + con.commit()
208 + _ensure_ka_id(con, uid)
209 + con.close()
210 +
211 + session = _sign({
212 + "uid": uid,
213 + "email": email,
214 + "name": name,
215 + "picture": picture,
216 + "exp": time.time() + SESSION_DAYS * 86400,
217 + })
218 + resp = RedirectResponse("/bienvenue" if not role else "/?login=ok")
219 + resp.set_cookie(
220 + COOKIE, session,
221 + max_age=SESSION_DAYS * 86400,
222 + httponly=True,
223 + secure=_base_url(request).startswith("https"),
224 + samesite="lax",
225 + path="/",
226 + )
227 + return resp
228 +
229 +
110 230 # -- routes --------------------------------------------------------------------
111 231
112 232 @router.get("/auth/config")
113 233 def auth_config():
114 234 return {"google": bool(os.environ.get("GOOGLE_CLIENT_ID")
115 235 and os.environ.get("GOOGLE_CLIENT_SECRET")
116 − and os.environ.get("SESSION_SECRET"))}
236 + and os.environ.get("SESSION_SECRET")),
237 + "ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))}
117 238
118 239
119 240 @router.get("/auth/google/login")
@@ -206,18 +327,32 @@ def me(request: Request):
206 327 raise HTTPException(401, "compte introuvable")
207 328 ka_id = row["ka_id"] or _ensure_ka_id(con, row["id"])
208 329 con.close()
330 + try:
331 + socials = json.loads(row["socials"]) if row["socials"] else {}
332 + except ValueError:
333 + socials = {}
209 334 return {
210 335 "uid": row["id"],
211 336 "ka_id": ka_id,
212 337 "email": row["email"],
213 − "name": row["name"],
214 − "picture": row["picture"],
338 + "name": row["display_name"] or row["name"],
339 + "google_name": row["name"],
340 + "picture": row["avatar_url"] or row["picture"],
341 + "google_picture": row["picture"],
342 + "avatar_url": row["avatar_url"],
343 + "display_name": row["display_name"] or "",
344 + "bio": row["bio"] or "",
345 + "city": row["city"] or "",
346 + "phone": row["phone"] or "",
347 + "website": row["website"] or "",
348 + "socials": socials,
215 349 "public": bool(row["public"]),
216 350 "role": row["role"],
217 351 "org_source": row["org_source"],
218 352 "created_at": row["created_at"],
219 353 "last_login": row["last_login"],
220 − "provider": "google",
354 + "provider": "ka-id" if (row["google_sub"] or "").startswith("ka:")
355 + else "google",
221 356 }
222 357
223 358
@@ -245,10 +380,19 @@ def public_profile(ka_id: str):
245 380 con.close()
246 381 if row is None or not row["public"]:
247 382 raise HTTPException(404, "profil introuvable")
383 + try:
384 + socials = json.loads(row["socials"]) if row["socials"] else {}
385 + except ValueError:
386 + socials = {}
248 387 return {
249 388 "ka_id": row["ka_id"],
250 − "name": row["name"],
251 − "picture": row["picture"],
389 + "name": row["display_name"] or row["name"],
390 + "picture": row["avatar_url"] or row["picture"],
391 + "bio": row["bio"] or "",
392 + "city": row["city"] or "",
393 + "website": row["website"] or "",
394 + "socials": socials,
395 + "role": row["role"],
252 396 "created_at": row["created_at"],
253 397 }
254 398
modified louka/db.py +7 −0
@@ -164,6 +164,13 @@ _MIGRATIONS = {
164 164 "public": "INTEGER DEFAULT 0",
165 165 "role": "TEXT",
166 166 "org_source": "TEXT",
167 + "display_name": "TEXT", # nom choisi (prime sur le nom Google)
168 + "avatar_url": "TEXT", # photo téléversée (prime sur picture)
169 + "bio": "TEXT",
170 + "city": "TEXT",
171 + "phone": "TEXT",
172 + "website": "TEXT",
173 + "socials": "TEXT", # JSON {instagram, facebook, x, linkedin…}
167 174 },
168 175 }
169 176
added louka/profile.py +123 −0
@@ -0,0 +1,123 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# profile.py : personnalisation du profil membre
5 +# · PUT /api/me/profile — nom affiché, bio, ville, téléphone, site web,
6 +# réseaux sociaux (instagram, facebook, x, linkedin, tiktok, youtube)
7 +# · POST /api/me/avatar — téléversement de photo (Pillow : recadrage carré
8 +# centré + 512 px + JPEG), servie depuis /uploads/avatars/…
9 +# · DELETE /api/me/avatar — retour à l'avatar Google
10 +# Le nom affiché (display_name) prime sur le nom Google, qui est réécrasé
11 +# à chaque connexion ; l'avatar téléversé (avatar_url) prime sur `picture`.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import io
16 +import json
17 +import time
18 +from pathlib import Path
19 +
20 +from fastapi import APIRouter, HTTPException, Request, UploadFile
21 +from pydantic import BaseModel
22 +
23 +from . import db
24 +from .auth import current_user
25 +
26 +router = APIRouter(prefix="/api")
27 +
28 +ROOT = Path(__file__).resolve().parent.parent
29 +AVATAR_DIR = ROOT / "data" / "uploads" / "avatars"
30 +AVATAR_MAX_BYTES = 8 * 1024 * 1024 # 8 Mo avant traitement
31 +AVATAR_SIZE = 512 # carré final (px)
32 +
33 +SOCIAL_KEYS = ("instagram", "facebook", "x", "linkedin", "tiktok", "youtube")
34 +
35 +
36 +def _require(request: Request) -> dict:
37 + user = current_user(request)
38 + if user is None:
39 + raise HTTPException(401, "non connecté")
40 + return user
41 +
42 +
43 +class ProfileUpdate(BaseModel):
44 + display_name: str = ""
45 + bio: str = ""
46 + city: str = ""
47 + phone: str = ""
48 + website: str = ""
49 + socials: dict[str, str] = {}
50 +
51 +
52 +@router.put("/me/profile")
53 +def update_profile(request: Request, body: ProfileUpdate):
54 + user = _require(request)
55 + socials = {k: (body.socials.get(k) or "").strip()[:200]
56 + for k in SOCIAL_KEYS if (body.socials.get(k) or "").strip()}
57 + con = db.connect()
58 + con.execute(
59 + """UPDATE users SET display_name=?, bio=?, city=?, phone=?,
60 + website=?, socials=? WHERE id=?""",
61 + (body.display_name.strip()[:80], body.bio.strip()[:280],
62 + body.city.strip()[:60], body.phone.strip()[:40],
63 + body.website.strip()[:300],
64 + json.dumps(socials, ensure_ascii=False) if socials else "",
65 + user["uid"]))
66 + con.commit()
67 + con.close()
68 + return {"ok": True}
69 +
70 +
71 +@router.post("/me/avatar")
72 +async def upload_avatar(request: Request, file: UploadFile):
73 + """Photo de profil : recadrée carrée, 512 px, JPEG — remplace l'ancienne."""
74 + user = _require(request)
75 + data = await file.read()
76 + if len(data) > AVATAR_MAX_BYTES:
77 + raise HTTPException(413, "image trop lourde (max 8 Mo)")
78 + try:
79 + from PIL import Image, ImageOps
80 + img = Image.open(io.BytesIO(data))
81 + img.load()
82 + img = ImageOps.exif_transpose(img).convert("RGB")
83 + except Exception:
84 + raise HTTPException(400, "fichier illisible — envoyez une image (JPG, PNG, WebP…)")
85 + # recadrage carré centré puis 512 px
86 + side = min(img.size)
87 + left = (img.width - side) // 2
88 + top = (img.height - side) // 2
89 + img = img.crop((left, top, left + side, top + side))
90 + img = img.resize((AVATAR_SIZE, AVATAR_SIZE), Image.LANCZOS)
91 +
92 + con = db.connect()
93 + row = con.execute("SELECT ka_id, avatar_url FROM users WHERE id=?",
94 + (user["uid"],)).fetchone()
95 + ka = (row["ka_id"] or f"uid{user['uid']}") if row else f"uid{user['uid']}"
96 + AVATAR_DIR.mkdir(parents=True, exist_ok=True)
97 + fname = f"{ka}-{int(time.time())}.jpg"
98 + img.save(AVATAR_DIR / fname, "JPEG", quality=88)
99 + # purge des anciennes photos de ce membre
100 + for old in AVATAR_DIR.glob(f"{ka}-*.jpg"):
101 + if old.name != fname:
102 + old.unlink(missing_ok=True)
103 + url = f"/uploads/avatars/{fname}"
104 + con.execute("UPDATE users SET avatar_url=? WHERE id=?", (url, user["uid"]))
105 + con.commit()
106 + con.close()
107 + return {"avatar_url": url}
108 +
109 +
110 +@router.delete("/me/avatar")
111 +def delete_avatar(request: Request):
112 + """Retire la photo téléversée (retour à l'avatar Google)."""
113 + user = _require(request)
114 + con = db.connect()
115 + row = con.execute("SELECT ka_id FROM users WHERE id=?",
116 + (user["uid"],)).fetchone()
117 + if row and row["ka_id"]:
118 + for old in AVATAR_DIR.glob(f"{row['ka_id']}-*.jpg"):
119 + old.unlink(missing_ok=True)
120 + con.execute("UPDATE users SET avatar_url=NULL WHERE id=?", (user["uid"],))
121 + con.commit()
122 + con.close()
123 + return {"ok": True}
modified louka/web.py +9 −0
@@ -34,6 +34,15 @@ app.include_router(auth.router)
34 34 from . import accounts # noqa: E402 (import tardif : évite le cycle web<->accounts)
35 35 app.include_router(accounts.router)
36 36
37 +# personnalisation du profil (bio, réseaux sociaux, photo) — louka/profile.py
38 +from . import profile as user_profile # noqa: E402
39 +app.include_router(user_profile.router)
40 +
41 +# fichiers téléversés (photos de profil) — data/uploads/
42 +_UPLOADS = ROOT / "data" / "uploads"
43 +_UPLOADS.mkdir(parents=True, exist_ok=True)
44 +app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")
45 +
37 46
38 47 def _row_to_dict(row) -> dict:
39 48 d = dict(row)
modified requirements.txt +1 −0
@@ -7,3 +7,4 @@ beautifulsoup4>=4.12
7 7 reportlab>=4.0
8 8 pillow>=10.0
9 9 qrcode>=7.4
10 +python-multipart>=0.0.9
10 11