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

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 {