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:
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 1–64 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 });
|
||||
});
|
||||
}
|
||||
|
||||
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