Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s

This commit is contained in:
2026-04-28 18:56:28 +02:00
parent b06c77a1eb
commit 5ff05242a8
35 changed files with 2134 additions and 50 deletions

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.7.2",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.7.2",
"version": "0.8.0",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -0,0 +1,94 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, of } from 'rxjs';
/**
* Reflet de LicenseStatus (enum cote backend).
*/
export type LicenseStatus = 'NONE' | 'VALID' | 'GRACE' | 'EXPIRED' | 'UNVERIFIABLE';
export interface LicenseStatusDTO {
enabled: boolean;
status: LicenseStatus;
patreonUserId: string | null;
tierId: string | null;
instanceId: string | null;
expiresAt: string | null;
lastRefreshAttemptAt: string | null;
lastRefreshSucceeded: boolean | null;
betaChannelEnabled: boolean;
}
/**
* Reflet de UpdateCheckService.BetaStatus.
*/
export interface BetaStatusDTO {
enabled: boolean;
updateAvailable: boolean;
anyUnknown: boolean;
images: Array<{
image: string;
localDigest: string | null;
remoteDigest: string | null;
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
updateAvailable: boolean;
}>;
checkedAt: string;
disabledReason: string | null;
}
/**
* Service Angular pour la gestion de la licence Patreon.
* Tous les endpoints sont proteges par HTTP Basic (admin).
*/
@Injectable({ providedIn: 'root' })
export class LicenseService {
private readonly apiUrl = '/api/license';
private readonly authOptions = { withCredentials: true };
constructor(private http: HttpClient) {}
getStatus(): Observable<LicenseStatusDTO | null> {
return this.http.get<LicenseStatusDTO>(this.apiUrl, this.authOptions).pipe(
catchError(() => of(null))
);
}
getConnectUrl(): Observable<{ url: string } | null> {
return this.http.get<{ url: string }>(`${this.apiUrl}/connect-url`, this.authOptions).pipe(
catchError(() => of(null))
);
}
install(jwt: string): Observable<LicenseStatusDTO | { error: string }> {
return this.http.post<LicenseStatusDTO>(`${this.apiUrl}/install`, { jwt }, this.authOptions).pipe(
catchError((err) => of({ error: err?.error?.error ?? 'Echec de l\'installation' }))
);
}
disconnect(): Observable<boolean> {
return this.http.delete<void>(this.apiUrl, this.authOptions).pipe(
// Convertit en boolean : true = succes, false = erreur
// (catchError plus bas masque les detail HTTP)
catchError(() => of(false as any))
) as unknown as Observable<boolean>;
}
refresh(): Observable<LicenseStatusDTO | null> {
return this.http.post<LicenseStatusDTO>(`${this.apiUrl}/refresh`, null, this.authOptions).pipe(
catchError(() => of(null))
);
}
setBetaChannel(enabled: boolean): Observable<LicenseStatusDTO | null> {
return this.http.put<LicenseStatusDTO>(`${this.apiUrl}/beta-channel`, { enabled }, this.authOptions).pipe(
catchError(() => of(null))
);
}
checkBeta(): Observable<BetaStatusDTO | null> {
return this.http.get<BetaStatusDTO>('/api/admin/updates/check-beta', this.authOptions).pipe(
catchError(() => of(null))
);
}
}

View File

@@ -221,58 +221,205 @@
</div>
</section>
<!-- Bloc Mises a jour -->
<section class="card" *ngIf="config.updateCheckEnabled">
<!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) -->
<section class="card" *ngIf="config.updateCheckEnabled || licenseStatus?.enabled">
<h2>Mises a jour</h2>
<p class="hint">Verifie aupres du registry Docker si une nouvelle version
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont
exclus — ils sont mis a jour manuellement.</p>
<div class="form-row">
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
</div>
<!-- ====================================================== -->
<!-- Sous-section : canal stable -->
<!-- ====================================================== -->
<div class="channel-block" *ngIf="config.updateCheckEnabled">
<h3 class="channel-title">Canal stable</h3>
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent).
</div>
<div *ngIf="updateStatus?.enabled">
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
<ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
</li>
</ul>
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
<div class="form-row">
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
</div>
<div *ngIf="updateMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent).
</div>
<div *ngIf="updateStatus?.enabled">
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
<ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
</li>
</ul>
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button>
</div>
<div *ngIf="updateMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
</div>
</div>
</div>
<!-- ====================================================== -->
<!-- Sous-section : canal beta (Patreon) -->
<!-- ====================================================== -->
<div class="channel-block" *ngIf="licenseStatus?.enabled">
<h3 class="channel-title">
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
Canal beta &mdash; reserve aux patrons
</h3>
<p class="hint">
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere.
Le tier <strong>Compagnon</strong> (7&euro;/mois) ou superieur debloque ce canal.
</p>
<!-- Pas de licence installee -->
<ng-container *ngIf="licenseStatus?.status === 'NONE'">
<div class="form-row">
<button type="button" class="btn-primary" (click)="connectPatreon()">
<lucide-icon [img]="Link2" [size]="16"></lucide-icon>
<span>Connecter mon compte Patreon</span>
</button>
</div>
<p class="hint">
Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche
et colle-le ci-dessous.
</p>
<div class="form-row">
<label for="license-jwt">Token Patreon</label>
<input
id="license-jwt"
type="text"
[(ngModel)]="licenseJwtInput"
placeholder="eyJhbGciOiJFZERTQS..."
autocomplete="off"
>
</div>
<div class="form-row">
<button type="button" class="btn-primary" (click)="installLicense()" [disabled]="!licenseJwtInput.trim()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Activer la licence</span>
</button>
</div>
<div *ngIf="licenseError" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ licenseError }}</span>
</div>
</ng-container>
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif.</span>
</div>
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance.
Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant".
</span>
</div>
<div *ngIf="licenseStatus.status === 'EXPIRED'" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
</span>
</div>
<div *ngIf="licenseStatus.status === 'UNVERIFIABLE'" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
</div>
<ul class="license-info">
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ licenseStatus.tierId }}</li>
<li *ngIf="licenseStatus.expiresAt">
<strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
<span *ngIf="daysUntilExpiry !== null && daysUntilExpiry > 0">
(renouvellement dans {{ daysUntilExpiry }} jour<span *ngIf="daysUntilExpiry > 1">s</span>)
</span>
</li>
<li *ngIf="licenseStatus.lastRefreshAttemptAt">
<strong>Dernier refresh :</strong>
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
<span *ngIf="licenseStatus.lastRefreshSucceeded === true" class="badge-ok">OK</span>
<span *ngIf="licenseStatus.lastRefreshSucceeded === false" class="badge-warn">echec</span>
</li>
</ul>
<div class="form-row form-row-inline">
<label class="checkbox">
<input
type="checkbox"
[checked]="licenseStatus.betaChannelEnabled"
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
>
<span>Activer le canal beta</span>
</label>
</div>
<div class="form-row form-row-actions">
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ licenseLoading ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
<span>Deconnecter Patreon</span>
</button>
</div>
<!-- Etat du canal beta -->
<div *ngIf="licenseStatus.betaChannelEnabled" class="beta-status">
<div *ngIf="betaChecking" class="hint">Verification des images beta...</div>
<div *ngIf="!betaChecking && betaStatus && !betaStatus.enabled" class="hint">
Indisponible : {{ betaStatus.disabledReason }}
</div>
<div *ngIf="!betaChecking && betaStatus?.enabled">
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
<code>docker compose pull &amp;&amp; docker compose up -d</code>.</span>
</div>
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Verification beta impossible pour certaines images.</span>
</div>
<ul class="update-images" *ngIf="betaStatus?.images?.length">
<li *ngFor="let img of betaStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span>
</li>
</ul>
</div>
</div>
</ng-container>
</div>
</section>

View File

@@ -364,3 +364,103 @@
padding: 0.15rem 0.5rem;
border-radius: 3px;
}
// --- Sous-blocs canaux (stable / beta) ----------------------------------
.channel-block {
margin-top: 16px;
& + & {
margin-top: 28px;
padding-top: 22px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
&:first-child {
margin-top: 0;
}
}
.channel-title {
font-size: 1rem;
margin: 0 0 12px;
color: #c4b8e0;
display: flex;
align-items: center;
gap: 6px;
}
// --- Section Patreon / canal beta ---------------------------------------
.license-info {
list-style: none;
margin: 12px 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
li {
font-size: 0.9rem;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
strong {
color: #c4b8e0;
margin-right: 6px;
}
}
}
.form-row-inline {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.form-row-actions {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.btn-secondary.btn-danger {
border-color: rgba(220, 80, 80, 0.4);
color: #ff7878;
&:hover {
background: rgba(220, 80, 80, 0.12);
border-color: rgba(220, 80, 80, 0.6);
}
}
.beta-status {
margin-top: 16px;
code {
background: rgba(0, 0, 0, 0.3);
padding: 1px 5px;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.85em;
}
}
label.checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
cursor: pointer;
}
input[type="checkbox"]:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@@ -2,11 +2,12 @@ import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X } from 'lucide-angular';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
/**
* Ecran de parametrage du LLM utilise par le Brain.
@@ -37,6 +38,19 @@ export class SettingsComponent implements OnInit {
readonly Trash2 = Trash2;
readonly Plus = Plus;
readonly X = X;
readonly Heart = Heart;
readonly Link2 = Link2;
readonly Unlink = Unlink;
// --- Licence Patreon (canal beta) ---
licenseStatus: LicenseStatusDTO | null = null;
licenseLoading = false;
licenseError = '';
/** Token JWT colle par l'utilisateur apres OAuth. */
licenseJwtInput = '';
/** Etat du canal beta (digests des images privees). */
betaStatus: BetaStatusDTO | null = null;
betaChecking = false;
// --- Pull / delete de modeles Ollama ---
/** Dialog d'ajout de modele ouvert/ferme. */
@@ -105,7 +119,8 @@ export class SettingsComponent implements OnInit {
private settingsService: SettingsService,
private router: Router,
private updatesService: UpdatesService,
public config: ConfigService
public config: ConfigService,
private licenseService: LicenseService
) {}
ngOnInit(): void {
@@ -113,6 +128,117 @@ export class SettingsComponent implements OnInit {
if (this.config.updateCheckEnabled) {
this.checkUpdates();
}
this.loadLicense();
}
// --- Licence Patreon ---------------------------------------------------
loadLicense(): void {
this.licenseLoading = true;
this.licenseService.getStatus().subscribe({
next: (s) => {
this.licenseStatus = s;
this.licenseLoading = false;
if (s?.enabled && (s.status === 'VALID' || s.status === 'GRACE') && s.betaChannelEnabled) {
this.checkBeta();
}
},
error: () => { this.licenseLoading = false; }
});
}
/**
* Ouvre la page OAuth Patreon dans une nouvelle fenetre.
* L'utilisateur copie ensuite le JWT et le colle dans l'input ci-dessous.
*/
connectPatreon(): void {
this.licenseError = '';
this.licenseService.getConnectUrl().subscribe({
next: (r) => {
if (!r?.url) {
this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.';
return;
}
window.open(r.url, '_blank', 'noopener');
}
});
}
installLicense(): void {
const jwt = this.licenseJwtInput.trim();
if (!jwt) {
this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.';
return;
}
this.licenseError = '';
this.licenseService.install(jwt).subscribe((res) => {
if ((res as any)?.error) {
this.licenseError = (res as any).error;
return;
}
this.licenseStatus = res as LicenseStatusDTO;
this.licenseJwtInput = '';
this.successMessage = 'Compte Patreon connecte. L\'acces beta est actif.';
if (this.licenseStatus.betaChannelEnabled) {
this.checkBeta();
}
});
}
refreshLicense(): void {
this.licenseLoading = true;
this.licenseService.refresh().subscribe({
next: (s) => {
this.licenseStatus = s;
this.licenseLoading = false;
},
error: () => { this.licenseLoading = false; }
});
}
disconnectPatreon(): void {
if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return;
this.licenseService.disconnect().subscribe(() => {
this.licenseStatus = null;
this.betaStatus = null;
this.successMessage = 'Compte Patreon deconnecte.';
this.loadLicense();
});
}
toggleBetaChannel(enabled: boolean): void {
this.licenseService.setBetaChannel(enabled).subscribe({
next: (s) => {
if (s) this.licenseStatus = s;
if (enabled) this.checkBeta();
else this.betaStatus = null;
}
});
}
checkBeta(): void {
this.betaChecking = true;
this.licenseService.checkBeta().subscribe({
next: (s) => {
this.betaStatus = s;
this.betaChecking = false;
},
error: () => { this.betaChecking = false; }
});
}
/** Format human-readable des dates renvoyees par le backend. */
formatDate(iso: string | null | undefined): string {
if (!iso) return '';
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
/** Nombre de jours restants avant expiration JWT (peut etre negatif). */
get daysUntilExpiry(): number | null {
if (!this.licenseStatus?.expiresAt) return null;
const exp = new Date(this.licenseStatus.expiresAt).getTime();
const now = Date.now();
return Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
}
checkUpdates(): void {