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).
This commit is contained in:
67
packages/server/src/routes/settings.ts
Normal file
67
packages/server/src/routes/settings.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user