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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user