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:
2026-06-18 14:16:53 +02:00
parent 77b1e55fc0
commit 7ca93c554d
23 changed files with 1469 additions and 24 deletions

View File

@@ -19,6 +19,7 @@ import { registerRepoRoutes } from './routes/repos.js';
import { registerGroupRoutes } from './routes/groups.js';
import { registerWorktreeRoutes } from './routes/worktrees.js';
import { registerPushRoutes } from './routes/push.js';
import { registerSettingsRoutes } from './routes/settings.js';
import { registerFsRoutes } from './routes/fs.js';
import { registerWsGateway } from './ws/gateway.js';
@@ -98,6 +99,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerGroupRoutes(app, groups);
registerWorktreeRoutes(app, worktrees);
registerPushRoutes(app, push);
registerSettingsRoutes(app, db, config, serverVersion, push);
registerFsRoutes(app);
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).

View File

@@ -35,11 +35,60 @@ export class AuthService {
}
createToken(label: string): string {
return this.createTokenRecord(label).token;
}
/** Comme createToken mais renvoie aussi l'id (pour l'API de gestion des tokens). */
createTokenRecord(label: string): { id: string; token: string } {
const id = randomUUID();
const raw = `arb_${randomBytes(24).toString('hex')}`;
this.db
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
.run(randomUUID(), label, sha256(raw), new Date().toISOString());
return raw;
.run(id, label, sha256(raw), new Date().toISOString());
return { id, token: raw };
}
/** Tokens actifs (non révoqués), du plus ancien au plus récent. Ne renvoie JAMAIS le hash. */
listTokens(): Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }> {
return this.db
.prepare(
'SELECT id, label, created_at AS createdAt, last_used_at AS lastUsedAt FROM auth_tokens WHERE revoked_at IS NULL ORDER BY created_at',
)
.all() as Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }>;
}
/** Nombre de tokens actifs (non révoqués). */
countActiveTokens(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL').get() as { n: number }).n;
}
/**
* Révoque un token. Refuse de révoquer le DERNIER token actif (sinon lock-out total) → 'last'.
* 'ok' = révoqué ; 'not_found' = id inconnu ou déjà révoqué.
*/
revokeToken(id: string): 'ok' | 'last' | 'not_found' {
// Transaction : le check « dernier token » et l'UPDATE doivent être atomiques (garde
// anti lock-out robuste, même si un refactor futur introduisait de la concurrence).
this.db.exec('BEGIN IMMEDIATE');
try {
const row = this.db.prepare('SELECT id FROM auth_tokens WHERE id = ? AND revoked_at IS NULL').get(id) as
| { id: string }
| undefined;
let result: 'ok' | 'last' | 'not_found';
if (!row) {
result = 'not_found';
} else if (this.countActiveTokens() <= 1) {
result = 'last';
} else {
this.db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
result = 'ok';
}
this.db.exec('COMMIT');
return result;
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
}
verifyRawToken(raw: string): AuthContext | null {

View File

@@ -1,5 +1,12 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
import type {
CreateTokenRequest,
CreateTokenResponse,
LoginRequest,
LoginResponse,
MeResponse,
TokensListResponse,
} from '@arboretum/shared';
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
@@ -41,7 +48,12 @@ export function registerAuthRoutes(
});
app.get('/api/v1/auth/me', async (req, reply) => {
const res: MeResponse = { ok: true, tokenLabel: req.authContext?.label ?? 'unknown', serverVersion };
const res: MeResponse = {
ok: true,
tokenId: req.authContext?.tokenId ?? '',
tokenLabel: req.authContext?.label ?? 'unknown',
serverVersion,
};
return reply.send(res);
});
@@ -50,4 +62,35 @@ export function registerAuthRoutes(
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
return reply.send({ ok: true });
});
// ---- Gestion des tokens d'accès (onglet Réglages) ----
// Sous l'auth globale (preValidation). On ne renvoie jamais le hash ; la valeur en clair
// d'un nouveau token n'est exposée qu'une seule fois, à la création.
app.get('/api/v1/auth/tokens', async (req): Promise<TokensListResponse> => {
const current = req.authContext?.tokenId;
return { tokens: auth.listTokens().map((t) => ({ ...t, current: t.id === current })) };
});
app.post('/api/v1/auth/tokens', async (req, reply) => {
const body = req.body as Partial<CreateTokenRequest> | null;
const label = typeof body?.label === 'string' ? body.label.trim() : '';
if (label.length < 1 || label.length > 64) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 164 characters' } });
}
const { id, token } = auth.createTokenRecord(label);
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
});
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const result = auth.revokeToken(id);
if (result === 'not_found') {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
}
if (result === 'last') {
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
}
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
return reply.send({ ok: true });
});
}

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());
});
}