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

@@ -0,0 +1,143 @@
// Gestion des tokens d'accès via l'API REST (onglet Réglages) : create → list → revoke,
// flag « courant », jamais de hash exposé, garde anti lock-out sur le dernier token.
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildApp, type AppBundle } from '../src/app.js';
import { openDb, type Db } from '../src/db/index.js';
import type { Config } from '../src/config.js';
import type { CreateTokenResponse, MeResponse, TokensListResponse } from '@arboretum/shared';
// Mêmes stubs que les autres tests de routes : pas de vrai claude ni de vrai PTY.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
class FakePty {
pid = 424242;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
onData(): { dispose: () => void } {
return { dispose: () => {} };
}
onExit(): { dispose: () => void } {
return { dispose: () => {} };
}
}
return { default: { spawn: (): FakePty => new FakePty() } };
});
process.env.ARBORETUM_LOG = 'silent';
let dir: string;
let bundle: AppBundle;
let db: Db;
let token: string;
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'arboretum-tokens-'));
const dbPath = join(dir, 'tokens.db');
db = openDb(dbPath);
const config: Config = {
port: 7317,
bind: '127.0.0.1',
dbPath,
dataDir: dir,
allowedOrigins: [],
printToken: false,
claudeProjectsDir: join(dir, 'claude', 'projects'),
claudeSessionsDir: join(dir, 'claude', 'sessions'),
vapidContact: 'mailto:test@localhost',
};
bundle = buildApp(config, db, '0.0.0-test');
const t = bundle.auth.ensureBootstrapToken();
if (!t) throw new Error('bootstrap token attendu sur une base vierge');
token = t;
});
afterAll(async () => {
await bundle.app.close();
db.close();
rmSync(dir, { recursive: true, force: true });
});
describe('routes de gestion des tokens', () => {
it('GET /auth/me expose le tokenId courant', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
expect(res.statusCode).toBe(200);
const me = res.json() as MeResponse;
expect(typeof me.tokenId).toBe('string');
expect(me.tokenId.length).toBeGreaterThan(0);
});
it('liste le token initial et le marque « courant », sans jamais exposer de hash', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() });
expect(res.statusCode).toBe(200);
const body = res.json() as TokensListResponse;
expect(body.tokens).toHaveLength(1);
expect(body.tokens[0]?.label).toBe('initial');
expect(body.tokens[0]?.current).toBe(true);
// aucune fuite de hash / valeur en clair
expect(JSON.stringify(body)).not.toMatch(/token_hash|tokenHash/);
});
it('crée un token (valeur en clair renvoyée une fois), puis utilisable pour sauthentifier', async () => {
const res = await bundle.app.inject({
method: 'POST',
url: '/api/v1/auth/tokens',
headers: auth(),
payload: { label: 'laptop' },
});
expect(res.statusCode).toBe(201);
const created = res.json() as CreateTokenResponse;
expect(created.label).toBe('laptop');
expect(created.token).toMatch(/^arb_[0-9a-f]{48}$/);
// le nouveau token authentifie réellement
const me = await bundle.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${created.token}` },
});
expect((me.json() as MeResponse).tokenLabel).toBe('laptop');
});
it('rejette un label vide ou trop long (400)', async () => {
const empty = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: auth(), payload: { label: ' ' } });
expect(empty.statusCode).toBe(400);
const tooLong = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: auth(), payload: { label: 'x'.repeat(65) } });
expect(tooLong.statusCode).toBe(400);
});
it('révoque un token non courant (204), qui disparaît de la liste et nauthentifie plus', async () => {
const before = (await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() })).json() as TokensListResponse;
const victim = before.tokens.find((t) => !t.current);
expect(victim).toBeDefined();
const del = await bundle.app.inject({ method: 'DELETE', url: `/api/v1/auth/tokens/${victim!.id}`, headers: auth() });
expect(del.statusCode).toBe(200);
const after = (await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() })).json() as TokensListResponse;
expect(after.tokens.find((t) => t.id === victim!.id)).toBeUndefined();
});
it('404 sur un id inconnu', async () => {
const res = await bundle.app.inject({ method: 'DELETE', url: '/api/v1/auth/tokens/nope-xyz', headers: auth() });
expect(res.statusCode).toBe(404);
});
it('409 LAST_TOKEN : refuse de révoquer le dernier token actif', async () => {
const list = (await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() })).json() as TokensListResponse;
expect(list.tokens).toHaveLength(1); // seul le token courant subsiste
const res = await bundle.app.inject({ method: 'DELETE', url: `/api/v1/auth/tokens/${list.tokens[0]!.id}`, headers: auth() });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ error: { code: 'LAST_TOKEN' } });
});
it('sans authentification → 401', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens' });
expect(res.statusCode).toBe(401);
});
});