feat: ordre naturel des sections Configurations et avatar utilisateur #113
This commit is contained in:
@@ -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: |
|
||||
|
||||
+27
-1
@@ -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 `<section>`
|
||||
(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é
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
+48
-2
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.21.0"
|
||||
version = "2.22.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "<data-url>"}` (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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**,
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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 `<ul class="help-nav-list">` (TOC) et aux
|
||||
`<section>` 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 `<h2>` à
|
||||
l'intérieur d'une `<section>` sans id : elles sont désormais portées par la
|
||||
`<section>` 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) → `<input type="file" accept="image/png,image/jpeg,image/webp">`.
|
||||
- **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": "<data-url>"}` ;
|
||||
`""` 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
|
||||
`<img class="sidebar-user-avatar-img">` 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é).
|
||||
+315
-278
@@ -1581,28 +1581,131 @@
|
||||
</button>
|
||||
</div>
|
||||
<ul class="help-nav-list">
|
||||
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
|
||||
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
|
||||
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
|
||||
<li><a href="#cfg-search" class="help-nav-link" data-i18n="help.nav_search"></a></li>
|
||||
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
|
||||
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
|
||||
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
|
||||
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
|
||||
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
|
||||
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
|
||||
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
|
||||
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
|
||||
<li><a href="#cfg-ai" class="help-nav-link" data-i18n="settings.ai"></a></li>
|
||||
<li><a href="#cfg-sources" class="help-nav-link" data-i18n="config.section_sources">Sources connectées</a></li>
|
||||
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
|
||||
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
|
||||
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
|
||||
<li><a href="#cfg-tokens" class="help-nav-link" data-i18n="config.nav_tokens">🔑 Clés API & MCP</a></li>
|
||||
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
|
||||
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
|
||||
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
|
||||
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
|
||||
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
|
||||
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
|
||||
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="help-content" id="config-scroll">
|
||||
<div class="config-content">
|
||||
<!-- Profil -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-profile"
|
||||
>
|
||||
<h2 data-i18n="config.profile">👤 Profil utilisateur</h2>
|
||||
<p class="config-description" data-i18n="config.profile_desc">
|
||||
Préférences personnelles pour personnaliser
|
||||
l'expérience ObsiGate.
|
||||
</p>
|
||||
<div class="profile-form" id="profile-form">
|
||||
<div class="profile-field profile-avatar-field" id="profile-avatar-field">
|
||||
<span class="profile-avatar-label" id="profile-avatar-label" data-i18n="config.avatar_label">Photo de profil</span>
|
||||
<div class="profile-avatar-row">
|
||||
<div
|
||||
class="profile-avatar-preview"
|
||||
id="profile-avatar-preview"
|
||||
data-i18n-attr="title:config.avatar_choose;aria-label:config.avatar_choose"
|
||||
title="Choisir une image"
|
||||
aria-label="Choisir une image"
|
||||
>
|
||||
<img id="profile-avatar-img" alt="" hidden />
|
||||
<span class="profile-avatar-initials" id="profile-avatar-initials" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-avatar-overlay"
|
||||
id="profile-avatar-overlay"
|
||||
data-i18n-attr="title:config.avatar_choose;aria-label:config.avatar_choose"
|
||||
title="Choisir une image"
|
||||
aria-label="Choisir une image"
|
||||
>
|
||||
<i data-lucide="camera" style="width: 18px; height: 18px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="profile-avatar-actions">
|
||||
<button type="button" class="config-btn-secondary" id="profile-avatar-choose" data-i18n="config.avatar_choose">Choisir une image</button>
|
||||
<button type="button" class="config-btn-secondary profile-avatar-remove-btn" id="profile-avatar-remove" data-i18n="config.avatar_remove" hidden>Supprimer la photo</button>
|
||||
<input
|
||||
type="file"
|
||||
id="profile-avatar-input"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
/>
|
||||
<span class="profile-hint" data-i18n="config.avatar_hint">PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<label for="profile-lang" data-i18n="config.lang_label">Langue préférée</label>
|
||||
<select id="profile-lang" class="config-select">
|
||||
<option value="fr">Francais</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Espanol</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
<span class="profile-hint" data-i18n="config.lang_hint">Utilisée par l'autocomplétion et le ghost text.</span>
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<label for="profile-name" data-i18n="config.display_name">Nom d'affichage</label>
|
||||
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
|
||||
</div>
|
||||
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
|
||||
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
|
||||
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sécurité du compte (MFA) -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-security"
|
||||
>
|
||||
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
|
||||
<p class="config-description" data-i18n="settings.security_desc">
|
||||
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
|
||||
</p>
|
||||
<div id="mfa-settings">
|
||||
<div id="mfa-status" class="mfa-status-section">
|
||||
<div class="mfa-status-row">
|
||||
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
|
||||
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
|
||||
</div>
|
||||
<div id="mfa-setup-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Themes -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-themes"
|
||||
>
|
||||
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
|
||||
<p class="config-description" data-i18n="auto.caac40b9">
|
||||
Choisissez un thème visuel. Chaque thème
|
||||
offre un mode sombre, clair, contraste élevé et sépia.
|
||||
</p>
|
||||
<div class="theme-grid" id="theme-grid">
|
||||
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Performance Settings - Frontend -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
@@ -1748,6 +1851,182 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tag Filtering (existing) -->
|
||||
<section class="config-section help-section" id="cfg-tags">
|
||||
<h2 data-i18n="config.tag_filtering_label">Filtrage de tags</h2>
|
||||
<p class="config-description" data-i18n="auto.e0b8a606">
|
||||
Définissez les patterns de tags à masquer
|
||||
dans la sidebar. Vous pouvez utiliser des
|
||||
wildcards pour cibler les tags de template.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="config-filters-list"
|
||||
id="config-filters-list"
|
||||
></div>
|
||||
|
||||
<div class="config-add-pattern">
|
||||
<input
|
||||
type="text"
|
||||
id="config-pattern-input"
|
||||
placeholder="Ex: #<% ... %> ou #{{ ... }}"
|
||||
class="config-input"
|
||||
/>
|
||||
<button id="config-add-btn"
|
||||
class="config-btn-add" data-i18n="config.add_webhook">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="config-regex-preview"
|
||||
id="config-regex-preview"
|
||||
style="display: none"
|
||||
>
|
||||
<small
|
||||
>Regex :
|
||||
<code id="config-regex-code"></code
|
||||
></small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Hidden Files/Folders Configuration -->
|
||||
<section id="cfg-hidden-files" class="config-section help-section">
|
||||
<h2 data-i18n="auto.f8ba6127">🗂️ Fichiers cachés</h2>
|
||||
<p class="config-description">
|
||||
Contrôlez l'affichage des fichiers/dossiers
|
||||
cachés (commençant par <code>.</code>) par
|
||||
vault.
|
||||
</p>
|
||||
<p
|
||||
class="config-hint"
|
||||
style="
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
"
|
||||
>
|
||||
i️ <strong data-i18n="config.note">Note :</strong> Tous les fichiers
|
||||
sont toujours indexés et cherchables. Ce
|
||||
paramètre contrôle uniquement leur
|
||||
visibilité dans l'interface.
|
||||
</p>
|
||||
|
||||
<div id="hidden-files-vault-list">
|
||||
<!-- Vault-specific settings will be injected here -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="config-actions-row"
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<button
|
||||
class="config-btn-save"
|
||||
id="cfg-save-hidden-files"
|
||||
>
|
||||
💾 Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Watcher / Synchronisation -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-sync"
|
||||
>
|
||||
<h2 data-i18n="config.section_watcher">Synchronisation automatique</h2>
|
||||
<p class="config-description" data-i18n="auto.d33601dc">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-enabled"
|
||||
>Activer la surveillance</label
|
||||
>
|
||||
<label class="config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cfg-watcher-enabled"
|
||||
checked
|
||||
/>
|
||||
<span
|
||||
class="config-toggle-slider"
|
||||
></span>
|
||||
</label>
|
||||
<span class="config-hint"
|
||||
>Activer/désactiver la surveillance
|
||||
automatique des fichiers</span
|
||||
>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-polling"
|
||||
>Mode polling (fallback)</label
|
||||
>
|
||||
<label class="config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cfg-watcher-polling"
|
||||
/>
|
||||
<span
|
||||
class="config-toggle-slider"
|
||||
></span>
|
||||
</label>
|
||||
<span class="config-hint"
|
||||
>Forcer le mode polling au lieu de
|
||||
inotify natif (utile si le mode natif ne
|
||||
fonctionne pas)</span
|
||||
>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-interval"
|
||||
>Intervalle polling (s)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="cfg-watcher-interval"
|
||||
class="config-input config-input--num"
|
||||
min="1"
|
||||
max="30"
|
||||
step="1"
|
||||
value="5"
|
||||
/>
|
||||
<span class="config-hint"
|
||||
>Intervalle de scrutation en mode
|
||||
polling (1-30 secondes)</span
|
||||
>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-debounce"
|
||||
>Debounce (s)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="cfg-watcher-debounce"
|
||||
class="config-input config-input--num"
|
||||
min="0.5"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value="2"
|
||||
/>
|
||||
<span class="config-hint"
|
||||
>Délai avant traitement des changements
|
||||
groupés (0.5-10 secondes)</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Performance Settings - Backend -->
|
||||
<section id="cfg-backend-settings" class="config-section help-section">
|
||||
<h2 data-i18n="config.section_backend">
|
||||
@@ -1884,182 +2163,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tag Filtering (existing) -->
|
||||
<section class="config-section help-section">
|
||||
<h2 id="cfg-tags" data-i18n="config.tag_filtering_label">Filtrage de tags</h2>
|
||||
<p class="config-description" data-i18n="auto.e0b8a606">
|
||||
Définissez les patterns de tags à masquer
|
||||
dans la sidebar. Vous pouvez utiliser des
|
||||
wildcards pour cibler les tags de template.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="config-filters-list"
|
||||
id="config-filters-list"
|
||||
></div>
|
||||
|
||||
<div class="config-add-pattern">
|
||||
<input
|
||||
type="text"
|
||||
id="config-pattern-input"
|
||||
placeholder="Ex: #<% ... %> ou #{{ ... }}"
|
||||
class="config-input"
|
||||
/>
|
||||
<button id="config-add-btn"
|
||||
class="config-btn-add" data-i18n="config.add_webhook">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="config-regex-preview"
|
||||
id="config-regex-preview"
|
||||
style="display: none"
|
||||
>
|
||||
<small
|
||||
>Regex :
|
||||
<code id="config-regex-code"></code
|
||||
></small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Watcher / Synchronisation -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-sync"
|
||||
>
|
||||
<h2 data-i18n="config.section_watcher">Synchronisation automatique</h2>
|
||||
<p class="config-description" data-i18n="auto.d33601dc">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-enabled"
|
||||
>Activer la surveillance</label
|
||||
>
|
||||
<label class="config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cfg-watcher-enabled"
|
||||
checked
|
||||
/>
|
||||
<span
|
||||
class="config-toggle-slider"
|
||||
></span>
|
||||
</label>
|
||||
<span class="config-hint"
|
||||
>Activer/désactiver la surveillance
|
||||
automatique des fichiers</span
|
||||
>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-polling"
|
||||
>Mode polling (fallback)</label
|
||||
>
|
||||
<label class="config-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cfg-watcher-polling"
|
||||
/>
|
||||
<span
|
||||
class="config-toggle-slider"
|
||||
></span>
|
||||
</label>
|
||||
<span class="config-hint"
|
||||
>Forcer le mode polling au lieu de
|
||||
inotify natif (utile si le mode natif ne
|
||||
fonctionne pas)</span
|
||||
>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-interval"
|
||||
>Intervalle polling (s)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="cfg-watcher-interval"
|
||||
class="config-input config-input--num"
|
||||
min="1"
|
||||
max="30"
|
||||
step="1"
|
||||
value="5"
|
||||
/>
|
||||
<span class="config-hint"
|
||||
>Intervalle de scrutation en mode
|
||||
polling (1-30 secondes)</span
|
||||
>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-watcher-debounce"
|
||||
>Debounce (s)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="cfg-watcher-debounce"
|
||||
class="config-input config-input--num"
|
||||
min="0.5"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value="2"
|
||||
/>
|
||||
<span class="config-hint"
|
||||
>Délai avant traitement des changements
|
||||
groupés (0.5-10 secondes)</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Hidden Files/Folders Configuration -->
|
||||
<section id="cfg-hidden-files" class="config-section help-section">
|
||||
<h2 data-i18n="auto.f8ba6127">🗂️ Fichiers cachés</h2>
|
||||
<p class="config-description">
|
||||
Contrôlez l'affichage des fichiers/dossiers
|
||||
cachés (commençant par <code>.</code>) par
|
||||
vault.
|
||||
</p>
|
||||
<p
|
||||
class="config-hint"
|
||||
style="
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
"
|
||||
>
|
||||
i️ <strong data-i18n="config.note">Note :</strong> Tous les fichiers
|
||||
sont toujours indexés et cherchables. Ce
|
||||
paramètre contrôle uniquement leur
|
||||
visibilité dans l'interface.
|
||||
</p>
|
||||
|
||||
<div id="hidden-files-vault-list">
|
||||
<!-- Vault-specific settings will be injected here -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="config-actions-row"
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<button
|
||||
class="config-btn-save"
|
||||
id="cfg-save-hidden-files"
|
||||
>
|
||||
💾 Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
@@ -2316,72 +2419,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Themes -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-themes"
|
||||
>
|
||||
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
|
||||
<p class="config-description" data-i18n="auto.caac40b9">
|
||||
Choisissez un thème visuel. Chaque thème
|
||||
offre un mode sombre, clair, contraste élevé et sépia.
|
||||
</p>
|
||||
<div class="theme-grid" id="theme-grid">
|
||||
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Profil -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-profile"
|
||||
>
|
||||
<h2 data-i18n="config.profile">👤 Profil utilisateur</h2>
|
||||
<p class="config-description" data-i18n="config.profile_desc">
|
||||
Préférences personnelles pour personnaliser
|
||||
l'expérience ObsiGate.
|
||||
</p>
|
||||
<div class="profile-form" id="profile-form">
|
||||
<div class="profile-field">
|
||||
<label for="profile-lang" data-i18n="config.lang_label">Langue préférée</label>
|
||||
<select id="profile-lang" class="config-select">
|
||||
<option value="fr">Francais</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Espanol</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
<span class="profile-hint" data-i18n="config.lang_hint">Utilisée par l'autocomplétion et le ghost text.</span>
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<label for="profile-name" data-i18n="config.display_name">Nom d'affichage</label>
|
||||
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
|
||||
</div>
|
||||
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
|
||||
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
|
||||
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sécurité du compte (MFA) -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-security"
|
||||
>
|
||||
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
|
||||
<p class="config-description" data-i18n="settings.security_desc">
|
||||
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
|
||||
</p>
|
||||
<div id="mfa-settings">
|
||||
<div id="mfa-status" class="mfa-status-section">
|
||||
<div class="mfa-status-row">
|
||||
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
|
||||
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
|
||||
</div>
|
||||
<div id="mfa-setup-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clés API / MCP (#107) -->
|
||||
<section id="cfg-tokens" class="config-section help-section">
|
||||
<h2 data-i18n="config.section_tokens">🔑 Clés API & MCP</h2>
|
||||
@@ -2440,34 +2477,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Plugins -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-plugins"
|
||||
>
|
||||
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
|
||||
<p class="config-description" data-i18n="plugins.description">
|
||||
Extend ObsiGate with custom renderers, search filters, and editor actions.
|
||||
</p>
|
||||
<div id="plugins-settings-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-about"
|
||||
>
|
||||
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
|
||||
<div
|
||||
id="config-about"
|
||||
class="config-diagnostics"
|
||||
>
|
||||
<div class="config-diag-loading" data-i18n="common.loading">
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Webhooks -->
|
||||
<section id="cfg-webhooks" class="config-section help-section">
|
||||
<h2>🔔 Webhooks</h2>
|
||||
@@ -2499,13 +2508,41 @@
|
||||
</section>
|
||||
|
||||
<!-- Partages publics -->
|
||||
<section class="config-section help-section">
|
||||
<section class="config-section help-section" id="cfg-partages-publics">
|
||||
<h2 id="cfg-partages-publics">📤 Partages publics</h2>
|
||||
<p class="config-description">
|
||||
Liens de partage publics pour des documents
|
||||
(lecture seule, sans authentification).
|
||||
</p>
|
||||
<div id="shares-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Plugins -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-plugins"
|
||||
>
|
||||
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
|
||||
<p class="config-description" data-i18n="plugins.description">
|
||||
Extend ObsiGate with custom renderers, search filters, and editor actions.
|
||||
</p>
|
||||
<div id="plugins-settings-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-about"
|
||||
>
|
||||
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
|
||||
<div
|
||||
id="config-about"
|
||||
class="config-diagnostics"
|
||||
>
|
||||
<div class="config-diag-loading" data-i18n="common.loading">
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+25
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
|
||||
assert.ok(nav, "#config-nav not found");
|
||||
return [...nav[1].matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
// ── #113a: order — TOC and page in sync ─────────────────────────────────────
|
||||
test("index.html — #cfg-profile is the first TOC entry, #cfg-about the last", () => {
|
||||
const hrefs = tocHrefs();
|
||||
assert.ok(hrefs.length >= 10, "TOC must list the settings sections");
|
||||
assert.equal(hrefs[0], "cfg-profile", "Profile must come first");
|
||||
assert.equal(hrefs[hrefs.length - 1], "cfg-about", "About must come last");
|
||||
});
|
||||
|
||||
test("index.html — page sections appear in the exact TOC order", () => {
|
||||
const hrefs = tocHrefs();
|
||||
const start = indexHtml.indexOf('id="config-scroll"');
|
||||
assert.ok(start > 0, "#config-scroll not found");
|
||||
const region = indexHtml.slice(start);
|
||||
let prev = -1;
|
||||
for (const id of hrefs) {
|
||||
const pos = region.indexOf(`id="${id}"`);
|
||||
assert.ok(pos > 0, `section #${id} missing from the page`);
|
||||
assert.ok(pos > prev, `section #${id} is out of TOC order`);
|
||||
prev = pos;
|
||||
}
|
||||
});
|
||||
|
||||
test("index.html — TOC has no dead anchors and page has no orphan cfg sections", () => {
|
||||
const hrefs = tocHrefs();
|
||||
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
|
||||
assert.deepEqual(missing, [], `dead TOC anchors: ${missing.join(", ")}`);
|
||||
// Every cfg-* id declared on a <section> must be reachable from the TOC
|
||||
// (form-control ids like #cfg-debounce are not sections).
|
||||
const start = indexHtml.indexOf('id="config-scroll"');
|
||||
const region = indexHtml.slice(start, indexHtml.indexOf("Graph View Modal"));
|
||||
const pageIds = [...region.matchAll(/<section[^>]*id="(cfg-[^"]+)"/g)].map((m) => m[1]);
|
||||
const orphans = [...new Set(pageIds)].filter((id) => !hrefs.includes(id));
|
||||
assert.deepEqual(orphans, [], `page sections missing from the TOC: ${orphans.join(", ")}`);
|
||||
});
|
||||
|
||||
// ── #113b: avatar UI in the Profil section ──────────────────────────────────
|
||||
test("index.html — Profil section carries the avatar picker", () => {
|
||||
const section = indexHtml.match(
|
||||
/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/,
|
||||
);
|
||||
assert.ok(section, "#cfg-profile section not found");
|
||||
const html = section[0];
|
||||
for (const id of [
|
||||
"profile-avatar-field",
|
||||
"profile-avatar-preview",
|
||||
"profile-avatar-img",
|
||||
"profile-avatar-initials",
|
||||
"profile-avatar-input",
|
||||
"profile-avatar-choose",
|
||||
"profile-avatar-remove",
|
||||
"profile-avatar-error",
|
||||
]) {
|
||||
assert.match(html, new RegExp(`id="${id}"`), `#${id} missing from #cfg-profile`);
|
||||
}
|
||||
assert.match(html, /accept="image\/png,image\/jpeg,image\/webp"/,
|
||||
"the file input must restrict accepted image types");
|
||||
});
|
||||
|
||||
test("config.js — avatar flow: type/size guards, resize, PATCH, sidebar refresh", () => {
|
||||
assert.match(configJs, /_AVATAR_TYPES/, "accepted MIME types must be declared");
|
||||
assert.match(configJs, /image\/png[\s\S]{0,80}image\/jpeg[\s\S]{0,80}image\/webp/,
|
||||
"PNG/JPEG/WebP must be the accepted types");
|
||||
assert.match(configJs, /_AVATAR_MAX_FILE_BYTES/, "raw file size guard missing");
|
||||
assert.match(configJs, /_resizeAvatarFile/, "client-side resize helper missing");
|
||||
assert.match(configJs, /toDataURL\(["']image\/jpeg["']/, "resize must export a JPEG data-URL");
|
||||
assert.match(configJs, /method:\s*["']PATCH["'][\s\S]{0,200}avatar/,
|
||||
"avatar must be persisted via PATCH /api/auth/me");
|
||||
assert.match(configJs, /avatar:\s*value|JSON\.stringify\(\{\s*avatar/,
|
||||
"the PATCH body must carry the avatar field");
|
||||
assert.match(configJs, /renderUserSection\(\)/,
|
||||
"the sidebar must be re-rendered after an avatar change");
|
||||
assert.match(configJs, /config\.avatar_invalid_type/, "invalid-type error missing");
|
||||
assert.match(configJs, /config\.avatar_too_large/, "too-large error missing");
|
||||
});
|
||||
|
||||
test("auth.js — sidebar circle renders the avatar image, initials as fallback", () => {
|
||||
assert.match(authJs, /user\.avatar/, "renderUserSection must read user.avatar");
|
||||
assert.match(authJs, /sidebar-user-avatar-img/, "the <img> must carry its CSS class");
|
||||
assert.match(authJs, /userInitials\(name\)/, "initials fallback removed");
|
||||
assert.match(authJs, /updateCachedUser/, "cached-user merge helper missing");
|
||||
assert.match(authJs, /isAuthEnabled\(/, "public auth-status helper missing");
|
||||
});
|
||||
|
||||
test("style.css — preview circle and sidebar image rules exist", () => {
|
||||
assert.match(css, /\.profile-avatar-preview\s*\{/, "no .profile-avatar-preview rule");
|
||||
assert.match(css, /\.profile-avatar-overlay\s*\{/, "no hover overlay rule");
|
||||
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
|
||||
});
|
||||
|
||||
// ── #113c: i18n + backend ───────────────────────────────────────────────────
|
||||
test("i18n — avatar keys exist in FR and EN", () => {
|
||||
const keys = [
|
||||
"config.avatar_label",
|
||||
"config.avatar_hint",
|
||||
"config.avatar_choose",
|
||||
"config.avatar_remove",
|
||||
"config.avatar_updated",
|
||||
"config.avatar_removed",
|
||||
"config.avatar_too_large",
|
||||
"config.avatar_invalid_type",
|
||||
"config.avatar_upload_failed",
|
||||
];
|
||||
for (const key of keys) {
|
||||
assert.ok(fr[key], `fr.json missing ${key}`);
|
||||
assert.ok(en[key], `en.json missing ${key}`);
|
||||
assert.notEqual(fr[key], key, `fr.json ${key} is untranslated`);
|
||||
assert.notEqual(en[key], key, `en.json ${key} is untranslated`);
|
||||
}
|
||||
});
|
||||
|
||||
test("backend — /api/auth/me validates the avatar data-URL", () => {
|
||||
assert.match(routerPy, /_AVATAR_DATA_URL_RE/, "data-URL regex missing");
|
||||
assert.match(routerPy, /def _validate_avatar/, "validation helper missing");
|
||||
assert.match(routerPy, /image\/\(\?:png\|jpeg\|webp\)/, "only PNG/JPEG/WebP allowed (no SVG)");
|
||||
assert.match(routerPy, /_AVATAR_MAX_CHARS/, "size cap missing");
|
||||
assert.match(routerPy, /"avatar": current_user\.get\("avatar"\)/, "GET /me must return the avatar");
|
||||
assert.match(routerPy, /avatar: str \| None = None/, "UpdateMeRequest must expose avatar");
|
||||
assert.match(routerPy, /updates\["avatar"\] = _validate_avatar/, "PATCH /me must validate before store");
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nSettings order & avatar tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll settings order & avatar tests passed.");
|
||||
}
|
||||
@@ -309,3 +309,97 @@ class TestAdmin:
|
||||
"Authorization": f"Bearer {token}",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Profile avatar (#113)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# 1×1 transparent PNG.
|
||||
TINY_PNG = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
|
||||
"z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
class TestAvatar:
|
||||
def _login(self, client):
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def _headers(self, token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_me_returns_avatar_field(self, auth_client):
|
||||
"""GET /api/auth/me exposes the avatar key (None by default)."""
|
||||
token = self._login(auth_client)
|
||||
resp = auth_client.get("/api/auth/me", headers=self._headers(token))
|
||||
assert resp.status_code == 200
|
||||
assert "avatar" in resp.json()
|
||||
assert resp.json()["avatar"] is None
|
||||
|
||||
def test_patch_me_sets_avatar(self, auth_client):
|
||||
"""A valid PNG data-URL is persisted and returned by GET /me."""
|
||||
token = self._login(auth_client)
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": TINY_PNG})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["avatar"] == TINY_PNG
|
||||
me = auth_client.get("/api/auth/me", headers=self._headers(token)).json()
|
||||
assert me["avatar"] == TINY_PNG
|
||||
|
||||
def test_patch_me_avatar_empty_string_clears(self, auth_client):
|
||||
"""Sending an empty string removes the avatar."""
|
||||
token = self._login(auth_client)
|
||||
auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": TINY_PNG})
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": ""})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["avatar"] is None
|
||||
|
||||
def test_patch_me_avatar_rejects_svg(self, auth_client):
|
||||
"""SVG data-URLs are refused (XSS surface)."""
|
||||
token = self._login(auth_client)
|
||||
svg = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": svg})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_me_avatar_rejects_non_image_payload(self, auth_client):
|
||||
"""A PNG data-URL header over non-image bytes is refused."""
|
||||
token = self._login(auth_client)
|
||||
fake = "data:image/png;base64,aGVsbG8gd29ybGQ=" # "hello world"
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": fake})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_me_avatar_rejects_too_large(self, auth_client):
|
||||
"""Payloads above the size cap are refused before any decode."""
|
||||
token = self._login(auth_client)
|
||||
huge = "data:image/png;base64," + "A" * 500_000
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": huge})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_me_avatar_rejects_invalid_base64(self, auth_client):
|
||||
"""Malformed base64 inside a well-formed data-URL is refused."""
|
||||
token = self._login(auth_client)
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": "data:image/png;base64,@@not-base64@@"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_login_user_payload_includes_avatar(self, auth_client):
|
||||
"""The login response user object carries the avatar for the sidebar."""
|
||||
token = self._login(auth_client)
|
||||
auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": TINY_PNG})
|
||||
# Re-login to get a fresh user payload.
|
||||
resp = auth_client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
||||
Reference in New Issue
Block a user