feat: onglets Réglages & Aide + icône Gitea

Réglages : préférences (langue, notifications push + test), gestion complète des tokens d'accès (liste/création/révocation, garde anti lock-out sur le dernier token), URL Gitea configurable, infos serveur en lecture seule (port/bind/origines/VAPID + flags CLI). Aide : documentation bilingue EN/FR de toutes les fonctionnalités, avec recherche. Icône Gitea (lien externe) dans la nav (sidebar + MoreSheet mobile).

Backend : routes /api/v1/auth/tokens (GET/POST/DELETE) + tokenId dans /me ; routes/settings.ts (GET/PATCH, allow-list stricte gitea_url, aucun secret exposé, URL validée http/https anti-XSS) ; AuthService.listTokens/revokeToken (transaction). Front : NavItem gère les liens externes, nav primaire/secondaire, store settings, vues SettingsView/HelpView.

236 tests verts (+15 nouveaux : auth-tokens, settings-routes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-18 14:16:53 +02:00
parent 0fd1d1f55c
commit 8cce1dc1e4
23 changed files with 1469 additions and 24 deletions

View File

@@ -0,0 +1,67 @@
// Réglages exposés à l'UI (onglet Réglages). Frontière de sécurité CENTRALE : la table `settings`
// contient aussi des SECRETS (server_secret, vapid_private). Ces routes n'exposent QUE des champs
// non sensibles et n'écrivent QUE des clés explicitement allow-listées — jamais les secrets.
import type { FastifyInstance } from 'fastify';
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
import type { Config } from '../config.js';
import { type Db, getSetting, setSetting } from '../db/index.js';
import type { PushService } from '../core/push-service.js';
// Seule clé de `settings` modifiable via l'API. Les secrets ne figurent JAMAIS ici.
const GITEA_URL_KEY = 'gitea_url';
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
function normalizeGiteaUrl(raw: string): string | null {
const trimmed = raw.trim();
if (trimmed === '') return null;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.toString();
}
export function registerSettingsRoutes(
app: FastifyInstance,
db: Db,
config: Config,
serverVersion: string,
push: PushService,
): void {
// '' (effacé) est normalisé en null côté réponse.
const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null;
const serverInfo = (): ServerInfo => ({
version: serverVersion,
port: config.port,
bind: config.bind,
allowedOrigins: config.allowedOrigins,
dataDir: config.dataDir,
vapidPublicKey: push.publicKey() || null,
vapidContact: config.vapidContact,
});
const snapshot = (): SettingsResponse => ({ settings: { giteaUrl: readGiteaUrl() }, server: serverInfo() });
app.get('/api/v1/settings', async (): Promise<SettingsResponse> => snapshot());
app.patch('/api/v1/settings', async (req, reply) => {
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {};
if ('giteaUrl' in body) {
const v = body.giteaUrl;
if (v === null || v === '') {
setSetting(db, GITEA_URL_KEY, ''); // effacement
} else if (typeof v === 'string') {
const normalized = normalizeGiteaUrl(v);
if (!normalized) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a valid http(s) URL' } });
}
setSetting(db, GITEA_URL_KEY, normalized);
} else {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } });
}
}
return reply.send(snapshot());
});
}