diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java new file mode 100644 index 0000000..c6323d5 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ConfigController.java @@ -0,0 +1,30 @@ +package com.loremind.infrastructure.web.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * Expose la configuration publique consommee par le frontend au demarrage. + * Activer le mode demo via la variable d'env DEMO_MODE=true : le front + * masque alors Settings / Export VTT, et les endpoints sensibles sont + * verrouilles cote serveur (cf. SettingsController). + */ +@RestController +@RequestMapping("/api/config") +public class ConfigController { + + private final boolean demoMode; + + public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) { + this.demoMode = demoMode; + } + + @GetMapping + public Map getPublicConfig() { + return Map.of("demoMode", demoMode); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java index b1257aa..501b02a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; import java.util.Map; @@ -32,20 +34,25 @@ public class SettingsController { private final RestTemplate restTemplate; private final String brainBaseUrl; + private final boolean demoMode; public SettingsController(RestTemplate restTemplate, - @Value("${brain.base-url}") String brainBaseUrl) { + @Value("${brain.base-url}") String brainBaseUrl, + @Value("${app.demo-mode:false}") boolean demoMode) { this.restTemplate = restTemplate; this.brainBaseUrl = brainBaseUrl; + this.demoMode = demoMode; } @GetMapping public ResponseEntity> getSettings() { + guardDemoMode(); return forward(HttpMethod.GET, "/settings", null); } @PutMapping public ResponseEntity> updateSettings(@RequestBody Map patch) { + guardDemoMode(); return forward(HttpMethod.PUT, "/settings", patch); } @@ -64,6 +71,12 @@ public class SettingsController { return forward(HttpMethod.GET, "/models/onemin", null); } + private void guardDemoMode() { + if (demoMode) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode"); + } + } + @SuppressWarnings({"rawtypes", "unchecked"}) private ResponseEntity> forward(HttpMethod method, String path, Object body) { HttpHeaders headers = new HttpHeaders(); diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties index 2026329..18d9358 100644 --- a/core/src/main/resources/application.properties +++ b/core/src/main/resources/application.properties @@ -21,13 +21,13 @@ spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # Configuration CORS pour autoriser le Frontend Angular -spring.web.cors.allowed-origins=http://localhost:4200 +spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200} spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS spring.web.cors.allowed-headers=* spring.web.cors.allow-credentials=true # Configuration du Brain (service IA Python) -brain.base-url=http://localhost:8000 +brain.base-url=${BRAIN_BASE_URL:http://localhost:8000} brain.timeout-seconds=120 # Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret). @@ -50,3 +50,7 @@ minio.bucket=${MINIO_BUCKET:loremind-images} # Limites d'upload d'images (MB) spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB + +# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings +# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics. +app.demo-mode=${DEMO_MODE:false} diff --git a/web/angular.json b/web/angular.json index 542b378..10f8631 100644 --- a/web/angular.json +++ b/web/angular.json @@ -60,7 +60,8 @@ "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { - "buildTarget": "web:build" + "buildTarget": "web:build", + "proxyConfig": "proxy.conf.json" } } } diff --git a/web/proxy.conf.json b/web/proxy.conf.json new file mode 100644 index 0000000..d1825a9 --- /dev/null +++ b/web/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:8080", + "secure": false, + "changeOrigin": true, + "logLevel": "warn" + } +} diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 79cad5c..9725732 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -1,4 +1,5 @@ import { Routes } from '@angular/router'; +import { hiddenInDemoGuard } from './guards/demo-mode.guard'; export const routes: Routes = [ { path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) }, @@ -30,6 +31,8 @@ export const routes: Routes = [ { path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) }, { path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, - { path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) }, + // Routes masquees en mode demo : ajouter canActivate: [hiddenInDemoGuard] + // (a prevoir aussi sur la future route d'export VTT). + { path: 'settings', canActivate: [hiddenInDemoGuard], loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) }, { path: '', redirectTo: '/lore', pathMatch: 'full' } ]; diff --git a/web/src/app/guards/demo-mode.guard.ts b/web/src/app/guards/demo-mode.guard.ts new file mode 100644 index 0000000..35b571f --- /dev/null +++ b/web/src/app/guards/demo-mode.guard.ts @@ -0,0 +1,17 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { ConfigService } from '../services/config.service'; + +/** + * Bloque l'acces aux routes sensibles quand demoMode est actif et redirige + * vers la home. Defense UX ; le verrou serveur reste la source de verite. + */ +export const hiddenInDemoGuard: CanActivateFn = () => { + const config = inject(ConfigService); + const router = inject(Router); + if (config.demoMode) { + router.navigate(['/']); + return false; + } + return true; +}; diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index cbd3aee..a4d9e21 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -45,8 +45,8 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character'; @Injectable({ providedIn: 'root' }) export class AiChatService { - private readonly loreEndpoint = 'http://localhost:8080/api/ai/chat/stream'; - private readonly campaignEndpoint = 'http://localhost:8080/api/ai/chat/stream-campaign'; + private readonly loreEndpoint = '/api/ai/chat/stream'; + private readonly campaignEndpoint = '/api/ai/chat/stream-campaign'; /** * Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore). diff --git a/web/src/app/services/campaign.service.ts b/web/src/app/services/campaign.service.ts index e3ebb00..b801d2a 100644 --- a/web/src/app/services/campaign.service.ts +++ b/web/src/app/services/campaign.service.ts @@ -30,7 +30,7 @@ export interface ChapterDeletionImpact { providedIn: 'root' }) export class CampaignService { - private apiUrl = 'http://localhost:8080/api/campaigns'; + private apiUrl = '/api/campaigns'; constructor(private http: HttpClient) {} @@ -60,73 +60,73 @@ export class CampaignService { // ========== ARC ========== getArcs(campaignId: string): Observable { - return this.http.get(`http://localhost:8080/api/arcs/campaign/${campaignId}`); + return this.http.get(`/api/arcs/campaign/${campaignId}`); } getArcById(id: string): Observable { - return this.http.get(`http://localhost:8080/api/arcs/${id}`); + return this.http.get(`/api/arcs/${id}`); } createArc(payload: ArcCreate): Observable { - return this.http.post('http://localhost:8080/api/arcs', payload); + return this.http.post('/api/arcs', payload); } updateArc(id: string, payload: ArcCreate): Observable { - return this.http.put(`http://localhost:8080/api/arcs/${id}`, payload); + return this.http.put(`/api/arcs/${id}`, payload); } deleteArc(id: string): Observable { - return this.http.delete(`http://localhost:8080/api/arcs/${id}`); + return this.http.delete(`/api/arcs/${id}`); } getArcDeletionImpact(id: string): Observable { - return this.http.get(`http://localhost:8080/api/arcs/${id}/deletion-impact`); + return this.http.get(`/api/arcs/${id}/deletion-impact`); } // ========== CHAPTER ========== getChapters(arcId: string): Observable { - return this.http.get(`http://localhost:8080/api/chapters/arc/${arcId}`); + return this.http.get(`/api/chapters/arc/${arcId}`); } getChapterById(id: string): Observable { - return this.http.get(`http://localhost:8080/api/chapters/${id}`); + return this.http.get(`/api/chapters/${id}`); } createChapter(payload: ChapterCreate): Observable { - return this.http.post('http://localhost:8080/api/chapters', payload); + return this.http.post('/api/chapters', payload); } updateChapter(id: string, payload: ChapterCreate): Observable { - return this.http.put(`http://localhost:8080/api/chapters/${id}`, payload); + return this.http.put(`/api/chapters/${id}`, payload); } deleteChapter(id: string): Observable { - return this.http.delete(`http://localhost:8080/api/chapters/${id}`); + return this.http.delete(`/api/chapters/${id}`); } getChapterDeletionImpact(id: string): Observable { - return this.http.get(`http://localhost:8080/api/chapters/${id}/deletion-impact`); + return this.http.get(`/api/chapters/${id}/deletion-impact`); } // ========== SCENE ========== getScenes(chapterId: string): Observable { - return this.http.get(`http://localhost:8080/api/scenes/chapter/${chapterId}`); + return this.http.get(`/api/scenes/chapter/${chapterId}`); } getSceneById(id: string): Observable { - return this.http.get(`http://localhost:8080/api/scenes/${id}`); + return this.http.get(`/api/scenes/${id}`); } createScene(payload: SceneCreate): Observable { - return this.http.post('http://localhost:8080/api/scenes', payload); + return this.http.post('/api/scenes', payload); } updateScene(id: string, payload: SceneCreate): Observable { - return this.http.put(`http://localhost:8080/api/scenes/${id}`, payload); + return this.http.put(`/api/scenes/${id}`, payload); } deleteScene(id: string): Observable { - return this.http.delete(`http://localhost:8080/api/scenes/${id}`); + return this.http.delete(`/api/scenes/${id}`); } search(q: string): Observable { diff --git a/web/src/app/services/character.service.ts b/web/src/app/services/character.service.ts index 9f5e793..fa7f12e 100644 --- a/web/src/app/services/character.service.ts +++ b/web/src/app/services/character.service.ts @@ -8,7 +8,7 @@ import { Character, CharacterCreate } from './character.model'; */ @Injectable({ providedIn: 'root' }) export class CharacterService { - private apiUrl = 'http://localhost:8080/api/characters'; + private apiUrl = '/api/characters'; constructor(private http: HttpClient) {} diff --git a/web/src/app/services/config.service.ts b/web/src/app/services/config.service.ts new file mode 100644 index 0000000..d5c7924 --- /dev/null +++ b/web/src/app/services/config.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +/** + * Configuration publique chargee une seule fois au demarrage via APP_INITIALIZER. + * Le flag demoMode bascule l'UI en mode vitrine (Settings/Export masques). + */ +export interface PublicConfig { + demoMode: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class ConfigService { + private config: PublicConfig = { demoMode: false }; + + constructor(private http: HttpClient) {} + + async load(): Promise { + try { + this.config = await firstValueFrom(this.http.get('/api/config')); + } catch { + // Si l'endpoint n'est pas joignable au boot, on reste sur le default + // (demoMode=false) pour ne pas bloquer l'app en dev. + } + } + + get demoMode(): boolean { + return this.config.demoMode; + } +} diff --git a/web/src/app/services/conversation.service.ts b/web/src/app/services/conversation.service.ts index 45fc234..57da9f7 100644 --- a/web/src/app/services/conversation.service.ts +++ b/web/src/app/services/conversation.service.ts @@ -17,7 +17,7 @@ import { */ @Injectable({ providedIn: 'root' }) export class ConversationService { - private readonly apiUrl = 'http://localhost:8080/api/conversations'; + private readonly apiUrl = '/api/conversations'; constructor(private http: HttpClient) {} diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index 4aca7a5..3f4aaa1 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -8,7 +8,7 @@ import { GameSystem, GameSystemCreate } from './game-system.model'; */ @Injectable({ providedIn: 'root' }) export class GameSystemService { - private apiUrl = 'http://localhost:8080/api/game-systems'; + private apiUrl = '/api/game-systems'; constructor(private http: HttpClient) {} diff --git a/web/src/app/services/image.service.ts b/web/src/app/services/image.service.ts index 4c4d8ff..95ac9bf 100644 --- a/web/src/app/services/image.service.ts +++ b/web/src/app/services/image.service.ts @@ -9,8 +9,8 @@ import { Image } from './image.model'; */ @Injectable({ providedIn: 'root' }) export class ImageService { - /** Base absolue du backend — utile pour construire des URLs complètes (). */ - readonly apiBase = 'http://localhost:8080'; + /** Base du backend (vide = même origine que le front, résolue par le navigateur). */ + readonly apiBase = ''; private apiUrl = `${this.apiBase}/api/images`; constructor(private http: HttpClient) {} diff --git a/web/src/app/services/lore.service.ts b/web/src/app/services/lore.service.ts index e9d85ad..fdbc54e 100644 --- a/web/src/app/services/lore.service.ts +++ b/web/src/app/services/lore.service.ts @@ -28,8 +28,8 @@ export interface LoreDeletionImpact { providedIn: 'root' }) export class LoreService { - private apiUrl = 'http://localhost:8080/api/lores'; - private nodesUrl = 'http://localhost:8080/api/lore-nodes'; + private apiUrl = '/api/lores'; + private nodesUrl = '/api/lore-nodes'; constructor(private http: HttpClient) {} diff --git a/web/src/app/services/page.service.ts b/web/src/app/services/page.service.ts index 9a3b23c..58e578a 100644 --- a/web/src/app/services/page.service.ts +++ b/web/src/app/services/page.service.ts @@ -10,7 +10,7 @@ import { Page, PageCreate } from './page.model'; */ @Injectable({ providedIn: 'root' }) export class PageService { - private apiUrl = 'http://localhost:8080/api/pages'; + private apiUrl = '/api/pages'; constructor(private http: HttpClient) {} diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts index 2ad4960..472a062 100644 --- a/web/src/app/services/settings.service.ts +++ b/web/src/app/services/settings.service.ts @@ -36,7 +36,7 @@ export interface OllamaModelInfo { @Injectable({ providedIn: 'root' }) export class SettingsService { - private readonly apiUrl = 'http://localhost:8080/api/settings'; + private readonly apiUrl = '/api/settings'; // HTTP Basic : le browser gere le prompt natif de credentials au premier 401. // withCredentials=true pour que les creds soient renvoyees sur les appels diff --git a/web/src/app/services/template.service.ts b/web/src/app/services/template.service.ts index 3899499..fe7de39 100644 --- a/web/src/app/services/template.service.ts +++ b/web/src/app/services/template.service.ts @@ -9,7 +9,7 @@ import { Template, TemplateCreate } from './template.model'; */ @Injectable({ providedIn: 'root' }) export class TemplateService { - private apiUrl = 'http://localhost:8080/api/templates'; + private apiUrl = '/api/templates'; constructor(private http: HttpClient) {} diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index 63c512f..a05ab00 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -53,11 +53,11 @@ Systèmes de JDR - - diff --git a/web/src/app/sidebar/sidebar.component.ts b/web/src/app/sidebar/sidebar.component.ts index 1248757..116c395 100644 --- a/web/src/app/sidebar/sidebar.component.ts +++ b/web/src/app/sidebar/sidebar.component.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router'; import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular'; import { LayoutService } from '../services/layout.service'; import { GlobalSearchService } from '../services/global-search.service'; +import { ConfigService } from '../services/config.service'; // Single source of truth pour la version affichée dans le footer : // on lit directement package.json à la compilation (resolveJsonModule). import packageJson from '../../../package.json'; @@ -30,7 +31,8 @@ export class SidebarComponent { constructor( private router: Router, private layoutService: LayoutService, - private globalSearch: GlobalSearchService + private globalSearch: GlobalSearchService, + public config: ConfigService ) { this.router.events.subscribe(() => { this.currentRoute = this.router.url; diff --git a/web/src/main.ts b/web/src/main.ts index 601ea02..044a7cc 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,6 +3,8 @@ import { AppComponent } from './app/app.component'; import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router'; import { routes } from './app/app.routes'; import { provideHttpClient } from '@angular/common/http'; +import { APP_INITIALIZER } from '@angular/core'; +import { ConfigService } from './app/services/config.service'; // withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular // telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la @@ -13,5 +15,11 @@ bootstrapApplication(AppComponent, { providers: [ provideRouter(routes, withPreloading(PreloadAllModules)), provideHttpClient(), + { + provide: APP_INITIALIZER, + useFactory: (config: ConfigService) => () => config.load(), + deps: [ConfigService], + multi: true, + }, ], }).catch((err: Error) => console.error(err));