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)
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 | |