From 33fe1a343991bef7ef4f108c8f7a22fcc9c0817c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 23 Sep 2026 22:18:26 -0400 Subject: [PATCH] feat: ordre naturel des sections Configurations et avatar utilisateur #113 --- .gitea/workflows/ci.yml | 1 + CHANGELOG.md | 28 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/auth/router.py | 50 +- backend/auth/user_store.py | 1 + desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/API_REST.md | 4 + docs/GUIDES/AUTHENTIFICATION_SECURITE.md | 5 + docs/GUIDES/PRISE_EN_MAIN.md | 6 + docs/ROADMAP.md | 5 +- docs/features/settings-order-avatar-113.md | 94 +++ frontend/index.html | 593 ++++++++++-------- frontend/js/auth.js | 26 +- frontend/js/config.js | 189 ++++++ frontend/locales/en.json | 9 + frontend/locales/fr.json | 9 + frontend/style.css | 62 ++ package.json | 2 +- tests/frontend/settings-order-avatar.test.mjs | 174 +++++ tests/test_auth_api.py | 96 ++- 24 files changed, 1078 insertions(+), 296 deletions(-) create mode 100644 docs/features/settings-order-avatar-113.md create mode 100644 tests/frontend/settings-order-avatar.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1565ad7..583c760 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: node tests/frontend/pdf-viewer.test.mjs node tests/frontend/forge-completion.test.mjs node tests/frontend/config-mobile.test.mjs + node tests/frontend/settings-order-avatar.test.mjs - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition) run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea8d8b..80f7a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.21.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.22.0**. --- @@ -14,6 +14,32 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.22.0] — 2026-09-23 + +### Ajouté + +- **#113 — avatar utilisateur.** La section « Profil » des Configurations permet de + **choisir / importer une image** (PNG, JPG, WEBP, 8 Mo max) : elle est recadrée en + carré 256 px côté client, validée côté serveur (data-URL + octets magiques, sans SVG) + et persistée sur le compte (`avatar` dans `data/users.json`, exposé par + `GET/PATCH /api/auth/me` et la réponse de login). L'image s'affiche dans le **cercle + du profil en bas de la sidebar** (initiales en repli) et peut être supprimée. UI : + aperçu circulaire avec overlay caméra au survol, boutons Choisir / Supprimer, erreurs + en ligne. Clés i18n FR/EN. + +### Modifié + +- **#113 — ordre des sections Configurations.** La TOC et la page sont réorganisées + dans un ordre naturel et **parfaitement synchronisées** : **Profil en premier**, + puis Sécurité, Thèmes, Recherche, Historique récent, Tags, Fichiers cachés, + Synchronisation, Backend, Diagnostics, IA, Sources connectées, Clés API & MCP, + Push, Webhooks, Partages publics, Plugins et **À propos en dernier**. Les ancres + `cfg-tags` et `cfg-partages-publics` sont désormais portées par leur `
` + (cible de défilement = haut de section). Garde-fous : test statique + `tests/frontend/settings-order-avatar.test.mjs` (ordre TOC = page, pas d'ancre morte). + +--- + ## [2.21.0] — 2026-09-23 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 68854c7..97858ef 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.21.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.22.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -975,8 +975,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.21.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.22.0). --- -*Projet : ObsiGate | Version : 2.21.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.22.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 968a565..6e81d7d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.21.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.22.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1150,8 +1150,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.21.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.22.0). --- -*Project: ObsiGate | Version: 2.21.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.22.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index db65e21..f48f82f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.21.0 +2.22.0 diff --git a/backend/auth/router.py b/backend/auth/router.py index 581e075..e9dfef9 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -2,6 +2,8 @@ # All /api/auth/* endpoints: login, logout, refresh, me, change-password, # and admin user CRUD. +import base64 +import binascii import logging import re @@ -109,6 +111,43 @@ class UpdateUserRequest(BaseModel): return validate_password_strength(v) +# ── Profile avatar (#113) ─────────────────────────────────────────── + +#: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface). +_AVATAR_DATA_URL_RE = re.compile( + r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$" +) +#: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom). +_AVATAR_MAX_CHARS = 400_000 + + +def _validate_avatar(data_url: str) -> str | None: + """Validate an avatar data-URL for storage on the user profile. + + Returns the normalized data-URL, or ``None`` when clearing the avatar + (empty string). Raises ``HTTPException(400)`` on anything else. + """ + if data_url == "": + return None + if len(data_url) > _AVATAR_MAX_CHARS: + raise HTTPException(400, "Avatar image too large") + if not _AVATAR_DATA_URL_RE.match(data_url): + raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL") + try: + raw = base64.b64decode(data_url.split(",", 1)[1], validate=True) + except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards + raise HTTPException(400, "Avatar payload is not valid base64") from exc + # Confirm the decoded bytes really are a supported image (magic numbers). + is_png = raw.startswith(b"\x89PNG\r\n\x1a\n") + is_jpeg = raw.startswith(b"\xff\xd8\xff") + is_webp = ( + len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP" + ) + if not (is_png or is_jpeg or is_webp): + raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image") + return data_url + + # ── Public endpoints ────────────────────────────────────────────────── @router.get("/status") @@ -221,6 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon "display_name": user["display_name"], "role": user["role"], "vaults": user["vaults"], + "avatar": user.get("avatar"), }, } @@ -343,6 +383,7 @@ async def get_me(current_user=Depends(require_auth)): "vaults": current_user["vaults"], "language": current_user.get("language", "fr"), "last_login": current_user.get("last_login"), + "avatar": current_user.get("avatar"), } @@ -350,19 +391,23 @@ class UpdateMeRequest(BaseModel): """Fields the user can update on their own profile.""" display_name: str | None = None language: str | None = None + #: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113). + avatar: str | None = None @router.patch("/me") async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)): - """Update current user's profile fields (display_name, language).""" + """Update current user's profile fields (display_name, language, avatar).""" from .user_store import update_user - updates = {} + updates: dict[str, object] = {} if req.display_name is not None: updates["display_name"] = req.display_name if req.language is not None: if req.language not in ("fr", "en"): raise HTTPException(400, "language must be 'fr' or 'en'") updates["language"] = req.language + if req.avatar is not None: + updates["avatar"] = _validate_avatar(req.avatar) if not updates: raise HTTPException(400, "No fields to update") updated = update_user(current_user["username"], updates) @@ -373,6 +418,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)): "vaults": updated["vaults"], "language": updated.get("language", "fr"), "last_login": updated.get("last_login"), + "avatar": updated.get("avatar"), } diff --git a/backend/auth/user_store.py b/backend/auth/user_store.py index e8da6ce..2562fad 100644 --- a/backend/auth/user_store.py +++ b/backend/auth/user_store.py @@ -96,6 +96,7 @@ def create_user( "vaults": vaults or [], "active": True, "language": "fr", # default UI language + "avatar": None, # profile picture data-URL (#113) "created_at": datetime.now(timezone.utc).isoformat(), "password_changed_at": datetime.now(timezone.utc).timestamp(), "last_login": None, diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index d5fabbb..a9acc01 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.21.0" +version = "2.22.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 56c371c..4ef8ea3 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.21.0" +version = "2.22.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 3d192c8..d79d03b 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.21.0", + "version": "2.22.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/API_REST.md b/docs/GUIDES/API_REST.md index 88292da..019cad4 100644 --- a/docs/GUIDES/API_REST.md +++ b/docs/GUIDES/API_REST.md @@ -221,6 +221,10 @@ Gestion : | `/api/auth/admin/users/{u}` | Modifier / supprimer | PATCH/DELETE | Admin | | `/api/admin/stats` · `/audit` · `/backup-stats` · `/stream` | Monitoring admin | GET | Admin | +> `PATCH /api/auth/me` accepte `{"avatar": ""}` (PNG/JPEG/WebP, 400 000 +> caractères max, octets magiques contrôlés) ; `{"avatar": ""}` supprime la photo. +> La valeur est renvoyée par `GET /api/auth/me` et par le payload `user` du login. + ### 3.10 Partage, webhooks, conflits, plugins, push | Endpoint | Description | Méthode | diff --git a/docs/GUIDES/AUTHENTIFICATION_SECURITE.md b/docs/GUIDES/AUTHENTIFICATION_SECURITE.md index 53dabf8..fd3325f 100644 --- a/docs/GUIDES/AUTHENTIFICATION_SECURITE.md +++ b/docs/GUIDES/AUTHENTIFICATION_SECURITE.md @@ -67,6 +67,11 @@ CHANGE THIS PASSWORD on first login! Changez-le immédiatement (menu profil → *Changer le mot de passe*). +Vous pouvez aussi ajouter une **photo de profil** : *Configurations → Profil → +Choisir une image* (PNG, JPG ou WEBP, 8 Mo maximum — recadrée en carré 256 px). +Elle remplace les initiales dans le cercle du compte en bas de la sidebar et peut +être supprimée à tout moment depuis la même section. + --- ## 3. Gestion des utilisateurs diff --git a/docs/GUIDES/PRISE_EN_MAIN.md b/docs/GUIDES/PRISE_EN_MAIN.md index c592262..9198642 100644 --- a/docs/GUIDES/PRISE_EN_MAIN.md +++ b/docs/GUIDES/PRISE_EN_MAIN.md @@ -142,6 +142,12 @@ Elle regroupe les vues principales via des icônes : Un champ **« Filtrer fichiers… »** restreint l'arborescence en temps réel, et le bouton **Aa** ajuste l'affichage des libellés. +En bas de la sidebar (si l'authentification est activée), la **section compte** +affiche votre avatar (ou vos initiales), votre nom et votre rôle ; un clic ouvre +le profil. La photo se choisit dans **Configurations → Profil** (*Choisir une +image* : PNG, JPG ou WEBP — recadrée en carré 256 px, affichée dans le cercle de +la sidebar) ; le bouton *Se déconnecter* est juste à côté. + ### 5.3 La zone de contenu Elle affiche l'onglet actif : tableau de bord **Statistiques**, **Bookmarks**, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9a040c6..4b5bcae 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.21.0 | **Dernière mise à jour :** 2026-09-23 +> **Version :** 2.22.0 | **Dernière mise à jour :** 2026-09-23 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -185,6 +185,7 @@ | 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) | | 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) | | 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) | +| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) | --- @@ -192,7 +193,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–112, #92 | ~130 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–113, #92 | ~131 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours | diff --git a/docs/features/settings-order-avatar-113.md b/docs/features/settings-order-avatar-113.md new file mode 100644 index 0000000..057539a --- /dev/null +++ b/docs/features/settings-order-avatar-113.md @@ -0,0 +1,94 @@ +# #113 — Ordre naturel des sections Configurations & avatar utilisateur + +> **Version livrée :** 2.22.0 · **Statut :** 🟢 · **Impact :** 🟡 +> **Zone :** frontend (`index.html`, `frontend/js/config.js`, `frontend/js/auth.js`, +> `frontend/style.css`, i18n FR/EN) + backend (`backend/auth/router.py`, +> `backend/auth/user_store.py`) + CI (`.gitea/workflows/ci.yml`). + +## Contexte + +Deux irritants sur la page **Configurations** : + +- l'ordre des sections était historique et peu naturel (Recherche en tête, Profil + noyé en position 11, À propos au milieu) ; +- la section **Profil** ne permettait pas de personnaliser l'image affichée dans le + cercle du compte en bas de la sidebar (initiales uniquement). + +## Ce qui a été livré + +### A. Ordre naturel des sections (TOC = page) + +Nouvel ordre, appliqué **à la liste `
    ` (TOC) et aux +`
    ` de la page**, dans le même ordre : + +1. **Profil** (`#cfg-profile`) — en premier +2. Sécurité du compte (`#cfg-security`) +3. Thèmes (`#cfg-themes`) +4. Paramètres de recherche (`#cfg-search`) +5. Historique récent (`#cfg-recent`) +6. Filtrage de tags (`#cfg-tags`) +7. Fichiers cachés (`#cfg-hidden-files`) +8. Synchronisation (`#cfg-sync`) +9. Paramètres backend (`#cfg-backend-settings`) +10. Diagnostics (`#cfg-diags`) +11. Clés API IA (`#cfg-ai`) +12. Sources connectées (`#cfg-sources`) +13. Clés API & MCP (`#cfg-tokens`) +14. Notifications push (`#cfg-push`) +15. Webhooks (`#cfg-webhooks`) +16. Partages publics (`#cfg-partages-publics`) +17. Plugins (`#cfg-plugins`) +18. **À propos** (`#cfg-about`) — en dernier + +Regroupement retenu : **Compte & apparence → Navigation & contenu → Système → +Intégrations & notifications → À propos**. + +Les ancres `cfg-tags` et `cfg-partages-publics` étaient posées sur un `

    ` à +l'intérieur d'une `
    ` sans id : elles sont désormais portées par la +`
    ` elle-même, pour que le scroll vise le haut de la section (comme toutes +les autres). + +### B. Avatar utilisateur ( Profil ) + +- **Import d'image** : bouton « Choisir une image » + overlay caméra au survol de + l'aperçu circulaire (88 px) → ``. +- **Traitement client** (`config.js`) : garde type (PNG/JPEG/WEBP) et taille brute + (8 Mo), **recadrage carré central** et redimensionnement à **256 px** via canvas, + export JPEG qualitée 0,85 (fond blanc pour les PNG transparents). +- **Persistance serveur** : `PATCH /api/auth/me` avec `{"avatar": ""}` ; + `""` supprime. Validation stricte dans `backend/auth/router.py` + (`_validate_avatar`) : data-URL PNG/JPEG/WebP uniquement (**SVG refusé** — surface + XSS), plafond 400 000 caractères, base64 valide et **octets magiques** contrôlés. + Champ `avatar` ajouté à `data/users.json` (`create_user`), renvoyé par + `GET/PATCH /api/auth/me` et par le payload `user` de login. +- **Affichage sidebar** (`auth.js`) : `renderUserSection()` insère un + `` dans `#sidebar-user-avatar` quand + `user.avatar` est défini, sinon les initiales (repli inchangé). Helpers + `AuthManager.updateCachedUser()` et `AuthManager.isAuthEnabled()`. +- **Suppression** : bouton « Supprimer la photo » (stylisté danger), revenu aux + initiales. +- **Auth désactivée** : le bloc avatar est masqué (pas de compte, sidebar masquée). +- Feedback : toasts `config.avatar_updated` / `config.avatar_removed`, erreurs en + ligne (`config.avatar_invalid_type`, `config.avatar_too_large`, + `config.avatar_upload_failed`). + +## Tests + +- `tests/test_auth_api.py` — classe `TestAvatar` (+8) : GET/PATCH exposent + l'avatar, data-URL PNG acceptée, `""` efface, SVG refusé, payload non-image refusé, + trop grand refusé, base64 invalide refusé, avatar présent dans le payload de login. +- `tests/frontend/settings-order-avatar.test.mjs` (nouveau, ajouté au job `lint`) — + 9 tests : ordre TOC (Profil 1er, À propos dernier), ordre de page strictement + identique à la TOC, aucune ancre morte / section orpheline, présence de l'UI avatar + dans `#cfg-profile`, flux `config.js` (types, taille, resize, PATCH, rafraîchissement + sidebar), rendu `auth.js`, règles CSS, clés i18n FR/EN, validation backend. +- Vérifications locales : pytest 1302 passed, ruff/mypy 0 erreur, tests frontend + statiques + JSDOM verts. + +## Limitations connues + +- L'avatar est stocké en data-URL dans `data/users.json` (adéquat pour un usage + personnel ; un stockage fichier dédié restera possible si les comptes se multiplient). +- La conversion GIF/animé n'est pas prise en charge (types PNG/JPEG/WEBP uniquement). +- Le nom d'affichage du profil reste local (`localStorage`) et n'est pas poussé au + serveur (comportement antérieur conservé). diff --git a/frontend/index.html b/frontend/index.html index 4834917..452af72 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1581,28 +1581,131 @@
    + +
    +

    👤 Profil utilisateur

    +

    + Préférences personnelles pour personnaliser + l'expérience ObsiGate. +

    +
    +
    + Photo de profil +
    +
    + + + +
    +
    + + + + PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. +
    +
    + +
    +
    + + + Utilisée par l'autocomplétion et le ghost text. +
    +
    + + +
    + + + +
    +
    + + +
    +

    🔒 Sécurité du compte

    +

    + Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte. +

    +
    +
    +
    + Authentification 2FA + Désactivée +
    +
    +
    +
    +
    + + +
    +

    🎨 Thèmes

    +

    + Choisissez un thème visuel. Chaque thème + offre un mode sombre, clair, contraste élevé et sépia. +

    +
    +
    Chargement...
    +
    +
    +
    + +
    +

    Filtrage de tags

    +

    + Définissez les patterns de tags à masquer + dans la sidebar. Vous pouvez utiliser des + wildcards pour cibler les tags de template. +

    + +
    + +
    + + +
    + + +
    + + +
    +

    🗂️ Fichiers cachés

    +

    + Contrôlez l'affichage des fichiers/dossiers + cachés (commençant par .) par + vault. +

    +

    + i️ Note : Tous les fichiers + sont toujours indexés et cherchables. Ce + paramètre contrôle uniquement leur + visibilité dans l'interface. +

    + +
    + +
    + +
    + +
    +
    + + +
    +

    Synchronisation automatique

    +

    + Surveillance des fichiers en temps réel via + watchdog. Les modifications sont détectées + automatiquement et l'index est mis à jour + sans redémarrage. +

    + +
    + + + Activer/désactiver la surveillance + automatique des fichiers +
    +
    + + + Forcer le mode polling au lieu de + inotify natif (utile si le mode natif ne + fonctionne pas) +
    +
    + + + Intervalle de scrutation en mode + polling (1-30 secondes) +
    +
    + + + Délai avant traitement des changements + groupés (0.5-10 secondes) +
    +
    +

    @@ -1884,182 +2163,6 @@

    - -
    -

    Filtrage de tags

    -

    - Définissez les patterns de tags à masquer - dans la sidebar. Vous pouvez utiliser des - wildcards pour cibler les tags de template. -

    - -
    - -
    - - -
    - - -
    - - -
    -

    Synchronisation automatique

    -

    - Surveillance des fichiers en temps réel via - watchdog. Les modifications sont détectées - automatiquement et l'index est mis à jour - sans redémarrage. -

    - -
    - - - Activer/désactiver la surveillance - automatique des fichiers -
    -
    - - - Forcer le mode polling au lieu de - inotify natif (utile si le mode natif ne - fonctionne pas) -
    -
    - - - Intervalle de scrutation en mode - polling (1-30 secondes) -
    -
    - - - Délai avant traitement des changements - groupés (0.5-10 secondes) -
    -
    - - -
    -

    🗂️ Fichiers cachés

    -

    - Contrôlez l'affichage des fichiers/dossiers - cachés (commençant par .) par - vault. -

    -

    - i️ Note : Tous les fichiers - sont toujours indexés et cherchables. Ce - paramètre contrôle uniquement leur - visibilité dans l'interface. -

    - -
    - -
    - -
    - -
    -
    -
    - -
    -

    🎨 Thèmes

    -

    - Choisissez un thème visuel. Chaque thème - offre un mode sombre, clair, contraste élevé et sépia. -

    -
    -
    Chargement...
    -
    -
    - - -
    -

    👤 Profil utilisateur

    -

    - Préférences personnelles pour personnaliser - l'expérience ObsiGate. -

    -
    -
    - - - Utilisée par l'autocomplétion et le ghost text. -
    -
    - - -
    - - - -
    -
    - - -
    -

    🔒 Sécurité du compte

    -

    - Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte. -

    -
    -
    -
    - Authentification 2FA - Désactivée -
    -
    -
    -
    -
    -

    🔑 Clés API & MCP

    @@ -2440,34 +2477,6 @@
    - -
    -

    🧩 Plugins

    -

    - Extend ObsiGate with custom renderers, search filters, and editor actions. -

    -
    -
    - - -
    -

    📦 À propos

    -
    -
    - Chargement... -
    -
    -
    -

    🔔 Webhooks

    @@ -2499,13 +2508,41 @@
    -
    +

    📤 Partages publics

    Liens de partage publics pour des documents (lecture seule, sans authentification).

    +
    + + +
    +

    🧩 Plugins

    +

    + Extend ObsiGate with custom renderers, search filters, and editor actions. +

    +
    +
    + + +
    +

    📦 À propos

    +
    +
    + Chargement... +
    +
    diff --git a/frontend/js/auth.js b/frontend/js/auth.js index e9269c2..d538e7c 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -125,6 +125,18 @@ const AuthManager = { return raw ? JSON.parse(raw) : null; }, + /** Merge fields into the cached user object (#113 — profile avatar). */ + updateCachedUser(fields) { + const next = { ...(this.getUser() || {}), ...fields }; + sessionStorage.setItem(this.USER_KEY, JSON.stringify(next)); + return next; + }, + + /** Whether authentication is enabled on this instance (#113). */ + isAuthEnabled() { + return !!this._authEnabled; + }, + isTokenExpired() { const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); if (!expiry) return true; @@ -354,7 +366,19 @@ const AuthManager = { const roleEl = document.getElementById("sidebar-user-role"); if (roleEl) roleEl.textContent = user.role === "admin" ? t("sidebar.user_role_admin") : t("sidebar.user_role_user"); const avatarEl = document.getElementById("sidebar-user-avatar"); - if (avatarEl) avatarEl.textContent = userInitials(name); + if (avatarEl) { + // #113: custom avatar image when set, initials as the fallback. + if (user.avatar) { + avatarEl.textContent = ""; + const img = document.createElement("img"); + img.src = user.avatar; + img.alt = ""; + img.className = "sidebar-user-avatar-img"; + avatarEl.appendChild(img); + } else { + avatarEl.textContent = userInitials(name); + } + } section.hidden = false; safeCreateIcons(); diff --git a/frontend/js/config.js b/frontend/js/config.js index edf7637..59d949f 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -2356,6 +2356,107 @@ function initAboutModal() { } // ── Profile ────────────────────────────────────────────────────────── +// #113 — profile picture: pick/import an image, center-crop it to a 256 px +// square JPEG, persist it on the account (PATCH /api/auth/me) and show it in +// the sidebar circle (#sidebar-user-avatar). Initials remain the fallback. +const _AVATAR_MAX_FILE_BYTES = 8 * 1024 * 1024; +const _AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"]; +const _AVATAR_SIZE = 256; + +function _profileInitials(name) { + var parts = String(name || "").trim().split(/\s+/).filter(Boolean); + if (!parts.length) return "?"; + var first = parts[0][0] || ""; + var last = parts.length > 1 ? parts[parts.length - 1][0] : ""; + return (first + last).toUpperCase() || "?"; +} + +/** Center-crop the picked image to a square and export it as a JPEG data-URL. */ +function _resizeAvatarFile(file) { + return new Promise(function (resolve, reject) { + var url = URL.createObjectURL(file); + var img = new Image(); + img.onload = function () { + URL.revokeObjectURL(url); + try { + var canvas = document.createElement("canvas"); + canvas.width = _AVATAR_SIZE; + canvas.height = _AVATAR_SIZE; + var ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, _AVATAR_SIZE, _AVATAR_SIZE); + var side = Math.min(img.naturalWidth, img.naturalHeight); + var sx = (img.naturalWidth - side) / 2; + var sy = (img.naturalHeight - side) / 2; + ctx.drawImage(img, sx, sy, side, side, 0, 0, _AVATAR_SIZE, _AVATAR_SIZE); + resolve(canvas.toDataURL("image/jpeg", 0.85)); + } catch (err) { + reject(err); + } + }; + img.onerror = function () { + URL.revokeObjectURL(url); + reject(new Error("image decode failed")); + }; + img.src = url; + }); +} + +function _profileAvatarError(key) { + var err = document.getElementById("profile-avatar-error"); + if (!err) return; + if (!key) { + err.textContent = ""; + err.classList.add("hidden"); + return; + } + err.textContent = t(key); + err.classList.remove("hidden"); +} + +/** Paint the settings preview (image or initials) for the given avatar. */ +function _renderProfileAvatar(dataUrl) { + var img = document.getElementById("profile-avatar-img"); + var initialsEl = document.getElementById("profile-avatar-initials"); + var removeBtn = document.getElementById("profile-avatar-remove"); + if (!img || !initialsEl) return; + if (dataUrl) { + img.src = dataUrl; + img.hidden = false; + initialsEl.textContent = ""; + if (removeBtn) removeBtn.hidden = false; + } else { + img.removeAttribute("src"); + img.hidden = true; + var user = AuthManager.getUser() || {}; + var name = localStorage.getItem("obsigate-name") || user.display_name || user.username || ""; + initialsEl.textContent = _profileInitials(name); + if (removeBtn) removeBtn.hidden = true; + } +} + +/** PATCH /api/auth/me with an avatar value (data-URL, or "" to clear). */ +async function _patchProfileAvatar(value) { + var headers = { "Content-Type": "application/json" }; + var authHeaders = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() : null; + if (authHeaders) headers = Object.assign(headers, authHeaders); + var res = await fetch("/api/auth/me", { + method: "PATCH", + headers: headers, + credentials: "include", + body: JSON.stringify({ avatar: value }), + }); + if (!res.ok) { + var detail = ""; + try { + detail = (await res.json()).detail || ""; + } catch (e) { /* no json body */ } + throw new Error(detail || "HTTP " + res.status); + } + return res.json(); +} + function initProfile() { var langSel = document.getElementById('profile-lang'); var nameInp = document.getElementById('profile-name'); @@ -2393,4 +2494,92 @@ function initProfile() { }); // Logout button — handled inline in index.html (onclick) + + // ── Avatar (#113) ──────────────────────────────────────────────── + var avatarField = document.getElementById('profile-avatar-field'); + var avatarInput = document.getElementById('profile-avatar-input'); + var chooseBtn = document.getElementById('profile-avatar-choose'); + var overlayBtn = document.getElementById('profile-avatar-overlay'); + var previewBox = document.getElementById('profile-avatar-preview'); + var removeBtn = document.getElementById('profile-avatar-remove'); + if (!avatarField || !avatarInput || !chooseBtn) return; + + // The avatar only exists on a real account — hide it when auth is off. + if (!AuthManager.isAuthEnabled || !AuthManager.isAuthEnabled()) { + avatarField.hidden = true; + return; + } + + _renderProfileAvatar((AuthManager.getUser() || {}).avatar || null); + + // Refresh from the server so the preview survives a page reload. + fetch('/api/auth/me', { credentials: 'include' }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (user) { + if (!user) return; + AuthManager.updateCachedUser(user); + _renderProfileAvatar(user.avatar || null); + }) + .catch(function () { /* non-bloquant */ }); + + function openPicker() { + _profileAvatarError(null); + avatarInput.click(); + } + chooseBtn.addEventListener('click', openPicker); + if (overlayBtn) overlayBtn.addEventListener('click', openPicker); + if (previewBox) { + previewBox.addEventListener('click', function (e) { + if (e.target.closest('button')) return; + openPicker(); + }); + } + + avatarInput.addEventListener('change', async function () { + var file = avatarInput.files && avatarInput.files[0]; + avatarInput.value = ""; // allow re-picking the same file + if (!file) return; + _profileAvatarError(null); + if (_AVATAR_TYPES.indexOf(file.type) === -1) { + _profileAvatarError('config.avatar_invalid_type'); + return; + } + if (file.size > _AVATAR_MAX_FILE_BYTES) { + _profileAvatarError('config.avatar_too_large'); + return; + } + chooseBtn.disabled = true; + try { + var dataUrl = await _resizeAvatarFile(file); + var user = await _patchProfileAvatar(dataUrl); + AuthManager.updateCachedUser(user); + _renderProfileAvatar(user.avatar || dataUrl); + AuthManager.renderUserSection(); + showToast(t('config.avatar_updated'), 'success'); + } catch (err) { + _profileAvatarError('config.avatar_upload_failed'); + console.error('Avatar upload failed:', err); + } finally { + chooseBtn.disabled = false; + } + }); + + if (removeBtn) { + removeBtn.addEventListener('click', async function () { + _profileAvatarError(null); + removeBtn.disabled = true; + try { + var user = await _patchProfileAvatar(""); + AuthManager.updateCachedUser(user); + _renderProfileAvatar(null); + AuthManager.renderUserSection(); + showToast(t('config.avatar_removed'), 'success'); + } catch (err) { + _profileAvatarError('config.avatar_upload_failed'); + console.error('Avatar removal failed:', err); + } finally { + removeBtn.disabled = false; + } + }); + } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 883c8db..aab90fd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -380,6 +380,15 @@ "config.delete_key": "Delete", "config.delete_key_confirm": "Delete key", "config.key_deleted": "Key deleted:", + "config.avatar_label": "Profile picture", + "config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.", + "config.avatar_choose": "Choose an image", + "config.avatar_remove": "Remove picture", + "config.avatar_updated": "Profile picture updated", + "config.avatar_removed": "Profile picture removed", + "config.avatar_too_large": "Image too large (8 MB max)", + "config.avatar_invalid_type": "Unsupported format — use PNG, JPG or WEBP", + "config.avatar_upload_failed": "Failed to save the profile picture", "config.backups": "Backups", "config.backups_desc": "Manage automatic file backups.", "config.client_config": "Client config", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 103e99c..815b7ec 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -380,6 +380,15 @@ "config.delete_key": "Supprimer", "config.delete_key_confirm": "Supprimer la clé", "config.key_deleted": "Clé supprimée :", + "config.avatar_label": "Photo de profil", + "config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.", + "config.avatar_choose": "Choisir une image", + "config.avatar_remove": "Supprimer la photo", + "config.avatar_updated": "Photo de profil mise à jour", + "config.avatar_removed": "Photo de profil supprimée", + "config.avatar_too_large": "Image trop lourde (8 Mo maximum)", + "config.avatar_invalid_type": "Format non pris en charge — utilisez PNG, JPG ou WEBP", + "config.avatar_upload_failed": "Échec de l'enregistrement de la photo de profil", "config.backups": "Sauvegardes", "config.backups_desc": "Gérez les sauvegardes automatiques de vos fichiers.", "config.client_config": "Configuration client", diff --git a/frontend/style.css b/frontend/style.css index a28fd03..ca935c4 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2616,6 +2616,60 @@ select { } .profile-hint { display: block; font-size: 0.65rem; color: var(--text-muted); margin-top: 4px; } .profile-saved { font-size: 0.7rem; color: var(--accent-green); margin-left: 10px; } +/* Profile picture picker (#113) */ +.profile-avatar-field { margin-bottom: 18px; } +.profile-avatar-label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; } +.profile-avatar-row { display: flex; align-items: center; gap: 16px; } +.profile-avatar-preview { + position: relative; + width: 88px; + height: 88px; + border-radius: 50%; + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #7c3aed)); + border: 2px solid var(--border); + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.profile-avatar-preview:hover, +.profile-avatar-preview:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); +} +.profile-avatar-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } +.profile-avatar-initials { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #fff; + user-select: none; +} +.profile-avatar-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border: 0; + padding: 0; + margin: 0; + background: rgba(0, 0, 0, 0.45); + color: #fff; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; +} +.profile-avatar-preview:hover .profile-avatar-overlay, +.profile-avatar-overlay:focus-visible { opacity: 1; } +.profile-avatar-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; } +.profile-avatar-actions .config-btn-secondary { font-size: 0.75rem; padding: 6px 14px; } +.profile-avatar-remove-btn { background: var(--danger-bg) !important; color: var(--danger) !important; border-color: var(--danger) !important; } +.profile-avatar-error { font-size: 0.7rem; color: var(--danger); margin-top: 8px; } +.profile-avatar-error.hidden { display: none; } .config-save-btn { padding: 6px 18px; font-size: 0.75rem; font-weight: 600; background: var(--accent); color: #fff; @@ -6086,6 +6140,14 @@ body.resizing-v { color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #7c3aed)); } +/* #113 — custom avatar image fills the circle; initials stay the fallback */ +.sidebar-user-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; +} .sidebar-user-meta { display: flex; flex-direction: column; diff --git a/package.json b/package.json index 31e7411..daadecc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.21.0", + "version": "2.22.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/settings-order-avatar.test.mjs b/tests/frontend/settings-order-avatar.test.mjs new file mode 100644 index 0000000..2585295 --- /dev/null +++ b/tests/frontend/settings-order-avatar.test.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +/** + * ObsiGate — Settings section order + profile avatar (#113). + * + * Static checks (no jsdom — runs in the "Frontend unit tests" CI step): + * - Order: the #config-nav TOC and the page sections must list the same + * anchors in the same order, with #cfg-profile first and #cfg-about last + * (regression guard for the reorganization). + * - Avatar UI: the Profil section must carry the picker (preview, file + * input, choose/remove buttons), config.js the resize/PATCH flow, auth.js + * the sidebar image rendering, style.css the preview/img rules, and the + * i18n keys must exist in FR and EN. + * - Backend: backend/auth/router.py must validate the avatar data-URL. + * + * Usage: node tests/frontend/settings-order-avatar.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, "..", ".."); + +const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8"); +const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8"); +const authJs = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8"); +const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); +const routerPy = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8"); +const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8")); +const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8")); + +function test(label, fn) { + try { + fn(); + console.log(" ✓ " + label); + } catch (err) { + console.error(" ✗ " + label + "\n " + err.message); + process.exitCode = 1; + } +} + +function tocHrefs() { + const nav = indexHtml.match(/