Compare commits

...
2 Commits
Author SHA1 Message Date
bruno 33fe1a3439 feat: ordre naturel des sections Configurations et avatar utilisateur #113
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m28s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 13m43s
2026-09-23 22:18:29 -04:00
bruno a726ad8511 feat: en-tete allege, compte utilisateur en sidebar et pellicule d'images defilable (molette + fleches) #112
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m29s
CI / test (push) Successful in 4m59s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 13m49s
2026-09-23 21:06:41 -04:00
30 changed files with 1626 additions and 334 deletions
+2
View File
@@ -38,9 +38,11 @@ jobs:
- name: Frontend unit tests
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/image-viewer.test.mjs
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: |
+49 -1
View File
@@ -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.20.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.22.0**.
---
@@ -14,6 +14,54 @@ 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é
- **#112 — section compte en bas de la sidebar.** L'identité (avatar avec
initiales, nom, rôle) et la **déconnexion** sont regroupées dans un bloc
épinglé au bas de la barre latérale, comme sur les sites professionnels ; un
clic sur l'identité ouvre le Profil. Nouvelle clef i18n FR/EN pour le rôle.
- **#112 — pellicule d'images défilable.** La molette de la souris fait défiler
horizontalement la bande de miniatures et deux **flèches translucides** sur les
côtés (révélées au survol) remplissent la même fonction.
### Modifié
- **#112 — en-tête allégé.** La **version** quitte le header pour le menu
**Options** (ligne « Version ») ; le **bouton de déconnexion** et le **nom de
l'utilisateur** sont retirés du header au profit de la section compte de la
sidebar. Le header droit ne conserve que l'indicateur hors-ligne, le contexte
vault et le bouton Options.
---
## [2.20.0] — 2026-09-23
### Ajouté
+3 -3
View File
@@ -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.20.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.20.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.20.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.22.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.20.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.20.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.22.0).
---
*Project: ObsiGate | Version: 2.20.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.22.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.20.0
2.22.0
+48 -2
View File
@@ -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"),
}
+1
View File
@@ -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,
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.20.0"
version = "2.22.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.20.0"
version = "2.22.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.20.0",
"version": "2.22.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+4
View File
@@ -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 |
+5
View File
@@ -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
+6
View File
@@ -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**,
+4 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.20.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)**
@@ -184,6 +184,8 @@
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 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) |
---
@@ -191,7 +193,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–111, #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 |
+69
View File
@@ -0,0 +1,69 @@
# #112 — En-tête allégé & compte en sidebar
> **Version livrée :** 2.21.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/auth.js`, `frontend/js/viewer.js`,
> `frontend/style.css`, i18n FR/EN) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Le header concentrait plusieurs éléments redondants ou peu lisibles :
- la **version** était affichée en permanence à côté du bouton Options ;
- un **bouton de déconnexion** et le **nom de l'utilisateur** occupaient la zone
droite du header, sans regroupement logique ;
- la pellicule de miniatures de la visionneuse d'images ne pouvait être
parcourue qu'au glisser (pas de molette horizontale ni de flèches dédiées).
## Ce qui a été livré
### A. Header allégé
- **Version déplacée** du header vers le menu **Options** : nouvelle ligne
non-interactive « Version » (`#version-badge` conservé pour `populateVersions`).
- **Suppression** du bloc `#user-menu` (nom + bouton « sign out ») et de la
ligne « Déconnexion » du menu Options. Le header droit ne garde que l'indicateur
hors-ligne, le contexte vault et le bouton Options.
### B. Section compte en bas de la sidebar
- Nouveau bloc `#sidebar-user` épinglé au bas de la sidebar (`flex-shrink: 0`,
bordure supérieure), à la manière d'un site web professionnel :
- initiales dans un **avatar** circulaire (dégradé d'accent) ;
- **nom** (`#sidebar-user-name`, classe `.user-display-name` conservée pour la
réutilisation par la collaboration) et **rôle** localisé
(Administrateur / Utilisateur) ;
- **bouton déconnexion** (`#sidebar-user-logout`) ;
- clic sur l'identité → **Profil** (ouvre la section `#cfg-profile`).
- `AuthManager.renderUserSection()` peuple le bloc ; il reste masqué quand
l'authentification est désactivée (`#sidebar-user[hidden]`). `renderUserMenu()`
est conservé comme alias rétro-compatible.
- La règle mobile qui masquait `.user-display-name` est limitée au header
(`.header-right .user-display-name`) pour que le nom reste visible dans la
sidebar mobile.
### C. Pellicule d'images défilable (#112, complément de #111)
- Nouveau conteneur `.image-nav` autour de la pellicule, avec deux **flèches
translucides** fixes (`.image-strip-arrow-prev/next`) révélées au survol.
- **Molette** au-dessus de la pellicule → défilement horizontal
(`strip.scrollLeft += deltaY`), conversion des deltas verticaux.
- Les flèches font défiler d'une « page » (`strip.scrollBy`, défilement doux).
## Tests
- `tests/frontend/unit.test.mjs` (+1) : header nettoyé (plus de `#user-menu`,
`#logout-btn`, ni version dans `.header-right`), version dans le menu Options,
présence de `#sidebar-user`, styles `.sidebar-user`/`.menu-list-version`, clés
i18n FR/EN.
- `tests/frontend/image-viewer.test.mjs` (+1) : wrapper `.image-nav`, flèches de
pellicule, molette et `scrollBy`.
- `tests/e2e/header-sidebar.spec.js` (nouveau) : test header/version exécuté
partout ; test compte en sidebar conditionné à l'auth (skip sinon).
- CI : `image-viewer.test.mjs` ajouté au job `lint` (il n'y était pas depuis #108).
## Limitations connues
- La section compte n'apparaît que si l'authentification est activée.
- `doLogoutFallback` (script inline supprimé avec la ligne du menu) reste appelé
par la section Profil uniquement en repli ; `window.handleLogout` est toujours
défini par `auth.js`, donc le repli n'est jamais atteint.
@@ -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é).
+372 -297
View File
@@ -519,9 +519,6 @@
></i>
<span id="vault-context-text">All</span>
</div>
<!-- User menu (visible when logged in) -->
<div class="user-menu" id="user-menu"></div>
<div class="version-badge" id="version-badge" title="Version ObsiGate"></div>
<div class="header-menu">
<button
class="header-menu-btn"
@@ -770,33 +767,33 @@
>
</span>
</button>
<script>function doLogoutFallback(){var x=new XMLHttpRequest();x.open('POST','/api/auth/logout');x.withCredentials=true;x.onload=function(){var a=document.getElementById('app');var l=document.getElementById('login-screen');if(a)a.classList.add('hidden');if(l)l.classList.remove('hidden');try{localStorage.removeItem('obsigate-token')}catch(e){}};x.send()}</script>
<button
class="menu-list-row menu-list-button"
id="logout-btn"
type="button"
role="menuitem"
onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}"
<div
class="menu-list-row menu-list-version"
role="none"
>
<span
class="menu-list-icon"
aria-hidden="true"
>
<i
data-lucide="log-out"
style="width: 17px; height: 17px; color: var(--danger)"
data-lucide="tag"
style="width: 17px; height: 17px"
></i>
</span>
<span class="menu-list-content">
<span class="menu-list-title" data-i18n="header.menu_logout"
style="color:var(--danger)"
>Deconnexion</span
>
<span class="menu-list-subtitle" data-i18n="header.menu_logout_desc"
>Quitter la session</span
<span class="menu-list-title" data-i18n="header.menu_version"
>Version</span
>
<span class="menu-list-subtitle"
>ObsiGate
<span
class="version-badge"
id="version-badge"
title="Version ObsiGate"
></span
></span>
</span>
</button>
</div>
</div>
</div>
</div>
@@ -1052,6 +1049,43 @@
<span>Aucune conversation</span>
</div>
</div>
<!-- User section (bottom of the sidebar, #112) -->
<div class="sidebar-user" id="sidebar-user" hidden>
<button
class="sidebar-user-profile"
id="sidebar-user-profile"
type="button"
title="Profil"
>
<span
class="sidebar-user-avatar"
id="sidebar-user-avatar"
aria-hidden="true"
></span>
<span class="sidebar-user-meta">
<span
class="sidebar-user-name user-display-name"
id="sidebar-user-name"
></span>
<span
class="sidebar-user-role"
id="sidebar-user-role"
></span>
</span>
</button>
<button
class="sidebar-user-logout"
id="sidebar-user-logout"
type="button"
title="Déconnexion"
>
<i
data-lucide="log-out"
style="width: 16px; height: 16px"
></i>
</button>
</div>
</aside>
<!-- Sidebar resize handle -->
@@ -1547,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"
@@ -1714,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: #&lt;% ... %&gt; 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">
@@ -1850,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: #&lt;% ... %&gt; 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"
@@ -2282,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 &amp; MCP</h2>
@@ -2406,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>
@@ -2465,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>
@@ -3293,6 +3364,10 @@
<strong data-i18n="sidebar.tab_tags">Tags</strong> <span data-i18n="help.desc_tags_tab">: Nuage de tags
cliquables</span>
</li>
<li>
<strong data-i18n="help.sidebar_user">Compte</strong> <span data-i18n="help.desc_sidebar_user">: Avatar, nom,
rôle et déconnexion en bas de la sidebar</span>
</li>
</ul>
<p data-i18n="help.sidebar_features">Fonctionnalités de la sidebar :</p>
<ul>
+69 -9
View File
@@ -1,7 +1,7 @@
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
import { state } from './state.js';
import { safeCreateIcons, escapeHtml } from './utils.js';
import { showToast, closeHeaderMenu } from './ui.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, getLocale, setLocale } from './i18n.js';
import { showWelcome } from './viewer.js';
@@ -13,6 +13,15 @@ window.handleLogout = () => {
AuthManager.logout();
};
/** Two-letter initials for the sidebar account avatar (#112). */
function userInitials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
const first = parts[0][0] || "";
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
@@ -116,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;
@@ -323,22 +344,58 @@ const AuthManager = {
const app = document.getElementById("app");
if (login) login.classList.add("hidden");
if (app) app.classList.remove("hidden");
this.renderUserMenu();
this.renderUserSection();
},
renderUserMenu() {
/**
* Render the account block pinned at the bottom of the sidebar (#112) —
* the user identity/logout no longer live in the header.
*/
renderUserSection() {
const user = this.getUser();
const userMenu = document.getElementById("user-menu");
if (!userMenu) return;
const section = document.getElementById("sidebar-user");
if (!user || !this._authEnabled) {
userMenu.innerHTML = "";
if (section) section.hidden = true;
return;
}
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
if (!section) return;
const name = user.display_name || user.username || "";
const nameEl = document.getElementById("sidebar-user-name");
if (nameEl) nameEl.textContent = name;
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) {
// #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();
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
const logoutBtn = document.getElementById("sidebar-user-logout");
if (logoutBtn && !logoutBtn._obsigateBound) {
logoutBtn._obsigateBound = true;
logoutBtn.addEventListener("click", () => this.logout());
}
const profileBtn = document.getElementById("sidebar-user-profile");
if (profileBtn && !profileBtn._obsigateBound) {
profileBtn._obsigateBound = true;
profileBtn.addEventListener("click", () => {
closeMobileSidebar();
const trigger = document.getElementById("profile-open-btn");
if (trigger) trigger.click();
});
}
const adminRow = document.getElementById("admin-menu-row");
if (adminRow) {
@@ -355,6 +412,9 @@ const AuthManager = {
}
},
// Backwards-compatible alias (older callers/tests may use this name).
renderUserMenu() { this.renderUserSection(); },
// ── Initialization ──────────────────────────────────────────────
async checkAuthStatus() {
+189
View File
@@ -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;
}
});
}
}
+36 -4
View File
@@ -700,8 +700,26 @@ export function renderImageViewer(area, data) {
container.appendChild(body);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
const strip = el("div", { class: "image-nav-strip", hidden: true });
container.appendChild(strip);
// #112 — the film scrolls with the mouse wheel and two translucent edge
// arrows; the scrollable strip is wrapped so the arrows stay fixed.
const navWrap = el("div", { class: "image-nav", hidden: true });
const strip = el("div", { class: "image-nav-strip" });
const stripPrev = el("button", {
class: "image-strip-arrow image-strip-arrow-prev",
type: "button",
title: t("viewer.image_strip_prev"),
"aria-label": t("viewer.image_strip_prev"),
}, [icon("chevron-left", 20)]);
const stripNext = el("button", {
class: "image-strip-arrow image-strip-arrow-next",
type: "button",
title: t("viewer.image_strip_next"),
"aria-label": t("viewer.image_strip_next"),
}, [icon("chevron-right", 20)]);
navWrap.appendChild(strip);
navWrap.appendChild(stripPrev);
navWrap.appendChild(stripNext);
container.appendChild(navWrap);
let scale = 1;
let tx = 0;
@@ -807,8 +825,8 @@ export function renderImageViewer(area, data) {
};
const renderStrip = () => {
if (siblings.length < 2) { strip.hidden = true; return; }
strip.hidden = false;
if (siblings.length < 2) { navWrap.hidden = true; return; }
navWrap.hidden = false;
strip.innerHTML = "";
thumbs = siblings.map((s, i) => {
const thumb = el("img", {
@@ -826,6 +844,20 @@ export function renderImageViewer(area, data) {
updateStripActive();
};
// #112 — scroll the film with the wheel (vertical → horizontal) and the
// two overlay arrows; the active image never changes, only the strip view.
const scrollStrip = (dir) => {
strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" });
};
stripPrev.addEventListener("click", () => scrollStrip(-1));
stripNext.addEventListener("click", () => scrollStrip(1));
strip.addEventListener("wheel", (e) => {
if (strip.scrollWidth <= strip.clientWidth) return;
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
strip.scrollLeft += delta;
e.preventDefault();
}, { passive: false });
(async () => {
try {
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
+17 -1
View File
@@ -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",
@@ -777,6 +786,7 @@
"header.menu_theme": "Theme",
"header.menu_theme_light": "Light",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profile",
"header.quick_help": "Quick Help",
"header.refresh": "Refresh",
@@ -1106,7 +1116,7 @@
"help.desc_markdown_all": ": Full markdown rendering",
"help.desc_md_render": "Markdown files are rendered with:",
"help.desc_menu_header": ": \"Vault\" dropdown in the Options menu",
"help.desc_menu_options": ": Access to settings, theme, help",
"help.desc_menu_options": ": Access to settings, theme, help and version",
"help.desc_op_ext": ": Filter by file type",
"help.desc_op_path": ": Filter by path",
"help.desc_op_phrase": ": Exact phrase search",
@@ -1309,6 +1319,8 @@
"help.sidebar_filtering": "Sidebar filtering",
"help.sidebar_section": "Sidebar",
"help.sidebar_tabs": "The sidebar has two tabs:",
"help.sidebar_user": "Account",
"help.desc_sidebar_user": ": Avatar, name, role and sign-out at the bottom of the sidebar",
"help.sidebar_toggle_btn": "Sidebar toggle button",
"help.simple": "Simple",
"help.simple_search": "Simple search",
@@ -1682,6 +1694,8 @@
"sidebar.tab_saved": "Searches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrator",
"sidebar.user_role_user": "User",
"sidebar.vault_context_all": "All vaults",
"sidebar.vault_specific": "Specific vault",
"sync.autocomplete": "Autocomplete",
@@ -1830,6 +1844,8 @@
"viewer.image_next": "Next image",
"viewer.image_open_original": "Open original",
"viewer.image_prev": "Previous image",
"viewer.image_strip_next": "Scroll thumbnails right",
"viewer.image_strip_prev": "Scroll thumbnails left",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom in",
"viewer.image_zoom_out": "Zoom out",
+17 -1
View File
@@ -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",
@@ -777,6 +786,7 @@
"header.menu_theme": "Thème",
"header.menu_theme_light": "Clair",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profil",
"header.quick_help": "Raccourcis & Astuces",
"header.refresh": "Rafraîchir",
@@ -1106,7 +1116,7 @@
"help.desc_markdown_all": ":\n Rendu complet du markdown",
"help.desc_md_render": "Les fichiers markdown sont rendus avec :",
"help.desc_menu_header": ": Dropdown \"Vault\" dans le menu Options",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide et version",
"help.desc_op_ext": ": Filtrer par type de fichier",
"help.desc_op_path": ": Filtrer par chemin",
"help.desc_op_phrase": ": Recherche de phrase entre guillemets",
@@ -1309,6 +1319,8 @@
"help.sidebar_filtering": "Filtrage de la sidebar",
"help.sidebar_section": "Sidebar (barre latérale)",
"help.sidebar_tabs": "La sidebar est divisée en deux onglets :",
"help.sidebar_user": "Compte",
"help.desc_sidebar_user": ": Avatar, nom, rôle et déconnexion en bas de la sidebar",
"help.sidebar_toggle_btn": "Bouton toggle sidebar",
"help.simple": "Simple",
"help.simple_search": "Recherche simple",
@@ -1682,6 +1694,8 @@
"sidebar.tab_saved": "Recherches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrateur",
"sidebar.user_role_user": "Utilisateur",
"sidebar.vault_context_all": "Toutes les vaults",
"sidebar.vault_specific": "Vault spécifique",
"sync.autocomplete": "Auto-complétion",
@@ -1830,6 +1844,8 @@
"viewer.image_next": "Image suivante",
"viewer.image_open_original": "Ouvrir l'original",
"viewer.image_prev": "Image précédente",
"viewer.image_strip_next": "Faire défiler les miniatures vers la droite",
"viewer.image_strip_prev": "Faire défiler les miniatures vers la gauche",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom avant",
"viewer.image_zoom_out": "Zoom arrière",
+210 -5
View File
@@ -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;
@@ -3815,8 +3869,8 @@ body.resizing-v {
gap: 8px;
}
.user-display-name {
display: none; /* Masquer le nom d'utilisateur sur mobile pour gagner de la place */
.header-right .user-display-name {
display: none; /* Masquer le nom d'utilisateur dans le header sur mobile */
}
.search-wrapper {
@@ -6039,6 +6093,108 @@ body.resizing-v {
color: var(--danger);
}
/* ---------------------------------------------------------------------------
Sidebar account section (#112) — identity + logout pinned at the bottom
--------------------------------------------------------------------------- */
.sidebar-user {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--bg-sidebar);
flex-shrink: 0;
}
.sidebar-user[hidden] {
display: none;
}
.sidebar-user-profile {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
cursor: pointer;
text-align: left;
transition: background 150ms ease, border-color 150ms ease;
}
.sidebar-user-profile:hover {
background: var(--bg-hover);
border-color: var(--border);
}
.sidebar-user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
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;
gap: 1px;
min-width: 0;
}
.sidebar-user-name {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary);
max-width: none;
}
.sidebar-user-role {
font-size: 0.7rem;
color: var(--text-muted);
}
.sidebar-user-logout {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
}
.sidebar-user-logout:hover {
color: var(--danger);
background: var(--bg-hover);
border-color: var(--border);
}
/* Version row inside the header options menu (#112) */
.menu-list-version {
cursor: default;
border-top: 1px solid var(--border);
margin-top: 4px;
padding-top: 8px;
}
.menu-list-version .version-badge {
margin-left: 4px;
}
/* ---------------------------------------------------------------------------
Admin Panel
--------------------------------------------------------------------------- */
@@ -9785,18 +9941,66 @@ body.desktop-mode .editor-container {
opacity: 0.55;
}
}
/* #112 — filmstrip wrapper so the scroll arrows stay fixed over the film */
.image-nav {
position: relative;
flex-shrink: 0;
background: var(--surface);
border-top: 1px solid var(--border);
}
.image-nav[hidden] {
display: none;
}
.image-nav-strip {
display: flex;
gap: 6px;
padding: 8px;
padding: 8px 40px;
overflow-x: auto;
background: var(--surface);
border-top: 1px solid var(--border);
scroll-behavior: smooth;
scrollbar-width: thin;
flex-shrink: 0;
}
.image-nav-strip[hidden] {
display: none;
}
/* Translucent arrows that scroll the film left/right (revealed on hover). */
.image-strip-arrow {
position: absolute;
top: 0;
bottom: 0;
width: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
color: var(--text-primary);
opacity: 0;
cursor: pointer;
z-index: 2;
transition: opacity 160ms ease;
}
.image-strip-arrow-prev {
left: 0;
background: linear-gradient(to right, var(--surface) 55%, transparent);
}
.image-strip-arrow-next {
right: 0;
background: linear-gradient(to left, var(--surface) 55%, transparent);
}
.image-nav:hover .image-strip-arrow {
opacity: 0.6;
}
.image-strip-arrow:hover,
.image-strip-arrow:focus-visible {
opacity: 1;
outline: none;
}
@media (hover: none) {
.image-strip-arrow {
opacity: 0.55;
}
}
.image-thumb {
width: 72px;
height: 72px;
@@ -9848,6 +10052,7 @@ body.desktop-mode .editor-container {
z-index: 2000;
background: rgba(0, 0, 0, 0.9);
}
.image-viewer-container.lightbox .image-nav,
.image-viewer-container.lightbox .image-nav-strip {
display: none;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.20.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": {
+67
View File
@@ -0,0 +1,67 @@
/**
* E2E — Header allégé + section compte en bas de la sidebar (#112).
*
* Le premier test s'exécute partout ; le second (section compte) nécessite
* l'authentification activée et se marque `skip` sinon (job CI e2e, serveur
* local `run-e2e-local` en auth désactivée). Pour le valider manuellement,
* lancer Playwright contre l'instance Docker de test (auth active) :
*
* BASE_URL=http://localhost:2020 OBSIGATE_PASS=test123 \
* npx playwright test tests/e2e/header-sidebar.spec.js --project=chromium-desktop
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function authEnabled(page) {
return page.evaluate(async () => {
try {
const r = await fetch('/api/auth/status');
const d = await r.json();
return !!d.auth_enabled;
} catch {
return false;
}
});
}
test.describe('Header allégé + compte en sidebar (#112)', () => {
test('le header ne porte plus utilisateur/version/déconnexion ; la version est dans le menu Options', async ({ page }) => {
await login(page);
await expect(page.locator('.header-right #user-menu')).toHaveCount(0);
await expect(page.locator('#logout-btn')).toHaveCount(0);
await expect(page.locator('.header-right > .version-badge')).toHaveCount(0);
await page.locator('#header-menu-btn').click();
await expect(page.locator('#header-menu-dropdown #version-badge')).toBeVisible();
});
test('le compte est affiché en bas de la sidebar (auth active)', async ({ page }) => {
await login(page);
test.skip(!(await authEnabled(page)), 'auth disabled in this environment');
await expect(page.locator('#sidebar-user')).toBeVisible();
await expect(page.locator('#sidebar-user-name')).not.toBeEmpty();
await expect(page.locator('#sidebar-user-role')).not.toBeEmpty();
await expect(page.locator('#sidebar-user-logout')).toBeVisible();
});
});
+27
View File
@@ -159,4 +159,31 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
await expect(container).toHaveAttribute('data-inplace', '1');
await expect(page.locator('#content-area .image-counter')).toContainText('/');
});
test('la pellicule est défilable (molette + flèches latérales) sans changer d\'image (#112)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const nav = page.locator('#content-area .image-nav');
const strip = page.locator('#content-area .image-nav-strip');
await expect(strip).toBeVisible({ timeout: 10000 });
await expect(nav).toBeAttached();
// Les deux flèches de défilement existent (translucides, révélées au survol).
const prev = page.locator('#content-area .image-strip-arrow-prev');
const next = page.locator('#content-area .image-strip-arrow-next');
await expect(prev).toBeAttached();
await expect(next).toBeAttached();
await expect
.poll(() => next.evaluate((el) => getComputedStyle(el).opacity))
.not.toBe('1'); // au repos, la flèche n'est pas pleinement visible
// Faire défiler la pellicule ne change PAS l'image courante.
const title = page.locator('#content-area .image-title');
const before = await title.innerText();
await next.hover();
await next.click();
await prev.click();
await expect(title).toHaveText(before);
});
});
+15
View File
@@ -122,6 +122,21 @@ test("image viewer navigates between siblings in place (#111)", () => {
assert.match(viewer, /const _imageDirCache = new Map\(\)/);
});
// #112 — the filmstrip scrolls horizontally (mouse wheel + two edge arrows).
test("image viewer filmstrip scrolls with the wheel and edge arrows (#112)", () => {
assert.match(viewer, /const navWrap = el\("div", \{ class: "image-nav", hidden: true \}\)/);
assert.match(viewer, /image-strip-arrow-prev/);
assert.match(viewer, /image-strip-arrow-next/);
assert.match(viewer, /strip\.addEventListener\("wheel"/);
assert.match(viewer, /strip\.scrollBy\(\{ left: dir/);
assert.match(viewer, /navWrap\.hidden = true/);
assert.match(viewer, /navWrap\.hidden = false/);
assert.match(css, /\.image-nav\s*\{[\s\S]*?position:\s*relative/);
assert.match(css, /\.image-strip-arrow\s*\{/);
assert.match(css, /\.image-strip-arrow-prev\s*\{\s*left:\s*0/);
assert.match(css, /\.image-strip-arrow-next\s*\{\s*right:\s*0/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-stage\s*\{/);
@@ -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.");
}
+45
View File
@@ -240,6 +240,50 @@ function testAIFabModuleExports() {
console.log(' ✓ ai-fab.js exports initAIFab');
}
// ── Test header trimming + sidebar account section (#112) ──────────────────
const AUTH_PATH = join(JS_DIR, 'auth.js');
const STYLE_PATH = join(FRONTEND_DIR, 'style.css');
function testSidebarUserSection() {
const html = readFileSync(INDEX_HTML, 'utf-8');
const auth = readFileSync(AUTH_PATH, 'utf-8');
const css = readFileSync(STYLE_PATH, 'utf-8');
// The header no longer carries the user block / version badge…
assert.doesNotMatch(html, /id="user-menu"/, 'header #user-menu must be removed');
assert.doesNotMatch(html, /id="logout-btn"/, 'header sign-out button must be removed');
const headerRight = html.slice(
html.indexOf('class="header-right"'),
html.indexOf('class="header-menu"'),
);
assert.doesNotMatch(headerRight, /version-badge/, 'version badge must leave the header-right');
assert.doesNotMatch(headerRight, /user-menu/, 'user block must leave the header-right');
// …the version lives in the options dropdown…
assert.match(html, /class="menu-list-row menu-list-version"[\s\S]*?id="version-badge"/);
// …and the account section sits at the bottom of the sidebar.
assert.match(html, /id="sidebar-user"/);
assert.match(html, /id="sidebar-user-logout"/);
assert.match(html, /id="sidebar-user-name"/);
assert.match(html, /id="sidebar-user-avatar"/);
// auth.js drives it.
assert.match(auth, /renderUserSection\(\)/);
assert.match(auth, /getElementById\("sidebar-user"\)/);
// styles + i18n exist in FR and EN.
assert.match(css, /\.sidebar-user\s*\{/);
assert.match(css, /\.menu-list-version\s*\{/);
for (const lang of ['fr', 'en']) {
const locale = JSON.parse(readFileSync(join(FRONTEND_DIR, 'locales', `${lang}.json`), 'utf-8'));
for (const key of ['header.menu_version', 'sidebar.user_role_admin', 'sidebar.user_role_user']) {
assert.ok(locale[key], `${lang}: missing i18n key ${key}`);
}
}
console.log(' ✓ header trimmed + sidebar account section (#112)');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -255,6 +299,7 @@ async function main() {
['ai-fab module exists', testAIFabModuleExists],
['ai-fab module exports', testAIFabModuleExports],
['config TOC icons', testConfigTocIcons],
['sidebar user section', testSidebarUserSection],
];
for (const [name, fn] of tests) {
+95 -1
View File
@@ -308,4 +308,98 @@ class TestAdmin:
resp = auth_client.post("/api/auth/logout", headers={
"Authorization": f"Bearer {token}",
})
assert resp.status_code == 200
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