From 9a52fde2aab97fba2713dc1cf184397924d3c57a Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 20:20:45 +0200 Subject: [PATCH 01/22] config gitea couverture plus test --- .gitea/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..754b900 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + +jobs: + test: + name: Tests & couverture + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm test -- --watch=false From 8cf169b837b787db2a5a01aecfda7634d49c7c4c Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 20:41:45 +0200 Subject: [PATCH 02/22] correction config action de gitea --- .gitea/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 754b900..53336a9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: https://github.com/actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: https://github.com/actions/setup-node@v4 with: node-version: '22' cache: 'npm' From 70ad0043a2b065d844f073afa72310b3dfafef92 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 21:26:08 +0200 Subject: [PATCH 03/22] ajoute version dans menu --- package.json | 2 +- src/app/menu/menu.css | 8 ++++++++ src/app/menu/menu.html | 2 ++ src/app/menu/menu.ts | 3 +++ tsconfig.json | 3 ++- 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c7cc323..6bdc09e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bonsai-webapp", - "version": "0.0.0", + "version": "0.1.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/src/app/menu/menu.css b/src/app/menu/menu.css index bb5cdb5..7801aab 100644 --- a/src/app/menu/menu.css +++ b/src/app/menu/menu.css @@ -63,3 +63,11 @@ border-left-color: #2563eb; font-weight: 600; } + +.sidebar-footer { + margin-top: auto; + padding: 0 0.5rem; + font-size: 0.72rem; + color: #9ca3af; + text-align: center; +} diff --git a/src/app/menu/menu.html b/src/app/menu/menu.html index 0558bc2..0acc1ff 100644 --- a/src/app/menu/menu.html +++ b/src/app/menu/menu.html @@ -39,4 +39,6 @@ } + +
v{{ version }}
diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts index 0a0fe3e..89709cb 100644 --- a/src/app/menu/menu.ts +++ b/src/app/menu/menu.ts @@ -1,5 +1,6 @@ import { Component } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; +import { version } from '../../../package.json'; @Component({ selector: 'app-menu', @@ -8,6 +9,8 @@ import { RouterLink, RouterLinkActive } from '@angular/router'; styleUrl: './menu.css', }) export class Menu { + protected readonly version = version; + protected readonly menuItems = [ { label: 'Accueil', path: '/home' }, { label: 'Projet', path: '/project' }, diff --git a/tsconfig.json b/tsconfig.json index 2ab7442..8ebe69f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "resolveJsonModule": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, From 9af9ddd8cb6ff0d53f3efb8f5ea439eea0cd39e2 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 21:45:56 +0200 Subject: [PATCH 04/22] =?UTF-8?q?config=20pour=20g=C3=A9n=C3=A9ration=20d'?= =?UTF-8?q?image=20docker=20=C3=A0=20la=20cr=C3=A9ation=20de=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 5 +++++ .gitea/workflows/release.yml | 37 ++++++++++++++++++++++++++++++++++++ Dockerfile | 13 +++++++++++++ nginx.conf | 12 ++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/release.yml create mode 100644 Dockerfile create mode 100644 nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a8348e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.gitea +coverage diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..01f2a84 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + release: + types: [published] + +jobs: + docker: + name: Build & push Docker image + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + + - name: Extract registry host from server URL + id: registry + run: | + HOST=$(echo '${{ gitea.server_url }}' | sed 's|https://||' | sed 's|http://||') + echo "host=${HOST}" >> $GITHUB_OUTPUT + echo "image=${HOST}/${{ gitea.repository }}" >> $GITHUB_OUTPUT + + - name: Login to Gitea container registry + uses: https://github.com/docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.host }} + username: ${{ gitea.actor }} + password: ${{ secrets.RELEASE_TOKEN }} + + - name: Build and push + uses: https://github.com/docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.registry.outputs.image }}:${{ gitea.ref_name }} + ${{ steps.registry.outputs.image }}:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0066358 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Stage 1 — Build Angular +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Stage 2 — Serve with nginx +FROM nginx:alpine +COPY --from=builder /app/dist/Bonsai-webapp/browser /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..5db99a0 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,12 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; +} From faacaa71780212c880608d736ab60970c03aefde Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 21:55:49 +0200 Subject: [PATCH 05/22] correction config release --- .gitea/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 01f2a84..794b7b4 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -25,7 +25,7 @@ jobs: with: registry: ${{ steps.registry.outputs.host }} username: ${{ gitea.actor }} - password: ${{ secrets.RELEASE_TOKEN }} + password: ${{ secrets.GITEA_TOKEN }} - name: Build and push uses: https://github.com/docker/build-push-action@v6 From 998660eaff20731be3e3c7f53e7ec27d36e31e31 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 22:03:51 +0200 Subject: [PATCH 06/22] correction config release --- .gitea/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 794b7b4..5bae755 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -25,7 +25,7 @@ jobs: with: registry: ${{ steps.registry.outputs.host }} username: ${{ gitea.actor }} - password: ${{ secrets.GITEA_TOKEN }} + password: ${{ secrets.TOKEN }} - name: Build and push uses: https://github.com/docker/build-push-action@v6 From 368f61e35caf04ed16d4a28c68682d1e30ab56a1 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 22:10:58 +0200 Subject: [PATCH 07/22] correction config release --- .gitea/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5bae755..5659c39 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -19,7 +19,14 @@ jobs: HOST=$(echo '${{ gitea.server_url }}' | sed 's|https://||' | sed 's|http://||') echo "host=${HOST}" >> $GITHUB_OUTPUT echo "image=${HOST}/${{ gitea.repository }}" >> $GITHUB_OUTPUT - + - name: Debug registry info + run: | + echo "Server URL: ${{ gitea.server_url }}" + echo "Actor: ${{ gitea.actor }}" + echo "Registry host: ${{ steps.registry.outputs.host }}" + echo "Token length: ${#TOKEN}" + env: + TOKEN: ${{ secrets.TOKEN }} - name: Login to Gitea container registry uses: https://github.com/docker/login-action@v3 with: From 0d60aadc4c1a7c3b4be5c001bd405844b73e8b14 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 22:14:22 +0200 Subject: [PATCH 08/22] correction config release --- .gitea/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5659c39..2bc2a73 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -24,15 +24,15 @@ jobs: echo "Server URL: ${{ gitea.server_url }}" echo "Actor: ${{ gitea.actor }}" echo "Registry host: ${{ steps.registry.outputs.host }}" - echo "Token length: ${#TOKEN}" + echo "Token length: ${#RELEASE_TOKEN}" env: - TOKEN: ${{ secrets.TOKEN }} + TOKEN: ${{ secrets.RELEASE_TOKEN }} - name: Login to Gitea container registry uses: https://github.com/docker/login-action@v3 with: registry: ${{ steps.registry.outputs.host }} username: ${{ gitea.actor }} - password: ${{ secrets.TOKEN }} + password: ${{ secrets.RELEASE_TOKEN }} - name: Build and push uses: https://github.com/docker/build-push-action@v6 From ac6ab4c694763c2900758740ebbe4523d9acbe78 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 22:20:16 +0200 Subject: [PATCH 09/22] correction config release --- .gitea/workflows/release.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2bc2a73..107440b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -8,7 +8,8 @@ jobs: docker: name: Build & push Docker image runs-on: ubuntu-latest - + container: + image: catthehacker/ubuntu:act-latest steps: - name: Checkout uses: https://github.com/actions/checkout@v4 @@ -19,14 +20,6 @@ jobs: HOST=$(echo '${{ gitea.server_url }}' | sed 's|https://||' | sed 's|http://||') echo "host=${HOST}" >> $GITHUB_OUTPUT echo "image=${HOST}/${{ gitea.repository }}" >> $GITHUB_OUTPUT - - name: Debug registry info - run: | - echo "Server URL: ${{ gitea.server_url }}" - echo "Actor: ${{ gitea.actor }}" - echo "Registry host: ${{ steps.registry.outputs.host }}" - echo "Token length: ${#RELEASE_TOKEN}" - env: - TOKEN: ${{ secrets.RELEASE_TOKEN }} - name: Login to Gitea container registry uses: https://github.com/docker/login-action@v3 with: From c2ad9a7bcadab319ae2945f9115beb5050a6d394 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 22:20:16 +0200 Subject: [PATCH 10/22] correction config release --- .gitea/workflows/release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 107440b..66ea8a2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -8,8 +8,6 @@ jobs: docker: name: Build & push Docker image runs-on: ubuntu-latest - container: - image: catthehacker/ubuntu:act-latest steps: - name: Checkout uses: https://github.com/actions/checkout@v4 From f0009d3f2a74eb762b02c802b12ee0f4aac618c2 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 22:20:16 +0200 Subject: [PATCH 11/22] correction config release --- .gitea/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 66ea8a2..4fdb3bf 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -18,6 +18,8 @@ jobs: HOST=$(echo '${{ gitea.server_url }}' | sed 's|https://||' | sed 's|http://||') echo "host=${HOST}" >> $GITHUB_OUTPUT echo "image=${HOST}/${{ gitea.repository }}" >> $GITHUB_OUTPUT + echo "DEBUG host=${HOST}" + echo "DEBUG server_url=${{ gitea.server_url }}" - name: Login to Gitea container registry uses: https://github.com/docker/login-action@v3 with: From d766378ef3a0f648ccd91efc7a1f46dee32b2eda Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 23:19:08 +0200 Subject: [PATCH 12/22] correction config release --- .gitea/workflows/release.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4fdb3bf..1f4bec4 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,9 +1,7 @@ name: Release - on: release: types: [published] - jobs: docker: name: Build & push Docker image @@ -12,18 +10,10 @@ jobs: - name: Checkout uses: https://github.com/actions/checkout@v4 - - name: Extract registry host from server URL - id: registry - run: | - HOST=$(echo '${{ gitea.server_url }}' | sed 's|https://||' | sed 's|http://||') - echo "host=${HOST}" >> $GITHUB_OUTPUT - echo "image=${HOST}/${{ gitea.repository }}" >> $GITHUB_OUTPUT - echo "DEBUG host=${HOST}" - echo "DEBUG server_url=${{ gitea.server_url }}" - name: Login to Gitea container registry uses: https://github.com/docker/login-action@v3 with: - registry: ${{ steps.registry.outputs.host }} + registry: git.goutailler-olivier.com username: ${{ gitea.actor }} password: ${{ secrets.RELEASE_TOKEN }} @@ -33,5 +23,5 @@ jobs: context: . push: true tags: | - ${{ steps.registry.outputs.image }}:${{ gitea.ref_name }} - ${{ steps.registry.outputs.image }}:latest + git.goutailler-olivier.com/${{ gitea.repository }}:${{ gitea.ref_name }} + git.goutailler-olivier.com/${{ gitea.repository }}:latest From 7fe79c9fa2cb7f2136bf8a282a0e3e4a16afba98 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 23:25:14 +0200 Subject: [PATCH 13/22] correction config release --- .gitea/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1f4bec4..ccf881c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -10,6 +10,9 @@ jobs: - name: Checkout uses: https://github.com/actions/checkout@v4 + - name: Set up Docker Buildx + uses: https://github.com/docker/setup-buildx-action@v3 + - name: Login to Gitea container registry uses: https://github.com/docker/login-action@v3 with: From 1f3456775193b8d83d955db8e719d4f96f3cc241 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 23:29:05 +0200 Subject: [PATCH 14/22] correction config release --- .gitea/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ccf881c..204cf59 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,5 +26,5 @@ jobs: context: . push: true tags: | - git.goutailler-olivier.com/${{ gitea.repository }}:${{ gitea.ref_name }} - git.goutailler-olivier.com/${{ gitea.repository }}:latest + git.goutailler-olivier.com/${{ gitea.repository_lower }}:${{ gitea.ref_name }} + git.goutailler-olivier.com/${{ gitea.repository_lower }}:latest From 13958be53e36cb9c56998c7a49870794e0c000be Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 23 May 2026 23:31:44 +0200 Subject: [PATCH 15/22] correction config release --- .gitea/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 204cf59..b1757b3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -20,11 +20,15 @@ jobs: username: ${{ gitea.actor }} password: ${{ secrets.RELEASE_TOKEN }} + - name: Set lowercase repo name + id: repo + run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + - name: Build and push uses: https://github.com/docker/build-push-action@v6 with: context: . push: true tags: | - git.goutailler-olivier.com/${{ gitea.repository_lower }}:${{ gitea.ref_name }} - git.goutailler-olivier.com/${{ gitea.repository_lower }}:latest + git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:${{ gitea.ref_name }} + git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:latest From 2098da0630f56dc69075a632097e96750b698718 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 06:50:13 +0200 Subject: [PATCH 16/22] Ajout auth --- angular.json | 5 +++++ package-lock.json | 14 ++++++++++++-- package.json | 1 + src/app/app.config.ts | 9 +++++++-- src/app/app.routes.ts | 9 +++++---- src/app/menu/menu.css | 38 +++++++++++++++++++++++++++++++++++++- src/app/menu/menu.html | 7 +++++++ src/app/menu/menu.ts | 8 +++++++- 8 files changed, 81 insertions(+), 10 deletions(-) diff --git a/angular.json b/angular.json index d7cc641..6f2761e 100644 --- a/angular.json +++ b/angular.json @@ -23,6 +23,11 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "**/*", + "input": "src/assets", + "output": "assets" } ], "styles": [ diff --git a/package-lock.json b/package-lock.json index 20e7ffc..ad85746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bonsai-webapp", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bonsai-webapp", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -15,6 +15,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.3", + "keycloak-js": "^26.2.4", "marked": "^18.0.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -6072,6 +6073,15 @@ ], "license": "MIT" }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", diff --git a/package.json b/package.json index 6bdc09e..5f2c9b6 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.3", + "keycloak-js": "^26.2.4", "marked": "^18.0.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e75614a..164478f 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,8 +1,13 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { KeycloakService } from './auth/keycloak.service'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideAppInitializer(() => inject(KeycloakService).init()), + ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 2ff2cd7..71ef07f 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,14 +3,15 @@ import { Home } from './home/home'; import { IssueDetail } from './issues/issue-detail/issue-detail'; import { Issues } from './issues/issues'; import { Projects } from './projects/projects'; +import { authGuard } from './auth/auth.guard'; export const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: 'home' }, { path: 'home', component: Home }, - { path: 'project', component: Projects }, + { path: 'project', component: Projects, canActivate: [authGuard] }, { path: 'projects', redirectTo: 'project' }, - { path: 'issues/new', component: IssueDetail }, - { path: 'issues/:id', component: IssueDetail }, - { path: 'issues', component: Issues }, + { path: 'issues/new', component: IssueDetail, canActivate: [authGuard] }, + { path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] }, + { path: 'issues', component: Issues, canActivate: [authGuard] }, { path: '**', redirectTo: 'home' }, ]; diff --git a/src/app/menu/menu.css b/src/app/menu/menu.css index 7801aab..e6e172a 100644 --- a/src/app/menu/menu.css +++ b/src/app/menu/menu.css @@ -64,8 +64,44 @@ font-weight: 600; } -.sidebar-footer { +.sidebar-user { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.6rem 0.75rem; + border-top: 1px solid #e5e7eb; margin-top: auto; +} + +.sidebar-user-name { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-logout { + width: 100%; + padding: 0.35rem 0.5rem; + font-size: 0.8rem; + font-weight: 500; + color: #6b7280; + background: transparent; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} + +.sidebar-logout:hover { + background: #fee2e2; + color: #dc2626; + border-color: #fca5a5; +} + +.sidebar-footer { padding: 0 0.5rem; font-size: 0.72rem; color: #9ca3af; diff --git a/src/app/menu/menu.html b/src/app/menu/menu.html index 0acc1ff..84b5e77 100644 --- a/src/app/menu/menu.html +++ b/src/app/menu/menu.html @@ -40,5 +40,12 @@ } + @if (keycloak.isAuthenticated()) { + + } +
v{{ version }}
diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts index 89709cb..ea4735c 100644 --- a/src/app/menu/menu.ts +++ b/src/app/menu/menu.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { version } from '../../../package.json'; +import { KeycloakService } from '../auth/keycloak.service'; @Component({ selector: 'app-menu', @@ -10,10 +11,15 @@ import { version } from '../../../package.json'; }) export class Menu { protected readonly version = version; + protected readonly keycloak = inject(KeycloakService); protected readonly menuItems = [ { label: 'Accueil', path: '/home' }, { label: 'Projet', path: '/project' }, { label: 'Issues', path: '/issues' }, ]; + + protected logout(): void { + this.keycloak.logout(); + } } From eeb074f7632d1cf53bfcdcab6a79d5e74369c424 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 06:50:30 +0200 Subject: [PATCH 17/22] Ajout auth - oublie --- src/app/auth/auth.guard.ts | 12 +++++++++ src/app/auth/keycloak.service.ts | 43 ++++++++++++++++++++++++++++++++ src/assets/silent-check-sso.html | 8 ++++++ 3 files changed, 63 insertions(+) create mode 100644 src/app/auth/auth.guard.ts create mode 100644 src/app/auth/keycloak.service.ts create mode 100644 src/assets/silent-check-sso.html diff --git a/src/app/auth/auth.guard.ts b/src/app/auth/auth.guard.ts new file mode 100644 index 0000000..c134bd4 --- /dev/null +++ b/src/app/auth/auth.guard.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { KeycloakService } from './keycloak.service'; + +export const authGuard: CanActivateFn = () => { + const keycloak = inject(KeycloakService); + if (keycloak.isLoggedIn()) { + return true; + } + keycloak.login(); + return false; +}; diff --git a/src/app/auth/keycloak.service.ts b/src/app/auth/keycloak.service.ts new file mode 100644 index 0000000..9517750 --- /dev/null +++ b/src/app/auth/keycloak.service.ts @@ -0,0 +1,43 @@ +import { Injectable, signal } from '@angular/core'; +import Keycloak from 'keycloak-js'; + +@Injectable({ providedIn: 'root' }) +export class KeycloakService { + private readonly keycloak = new Keycloak({ + url: 'https://auth.goutailler-olivier.com', + realm: 'bonsai', + clientId: 'bonsai-webapp', + }); + + readonly isAuthenticated = signal(false); + readonly username = signal(undefined); + + async init(): Promise { + try { + const authenticated = await this.keycloak.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: `${window.location.origin}/assets/silent-check-sso.html`, + pkceMethod: 'S256', + }); + this.isAuthenticated.set(authenticated); + if (authenticated) { + this.username.set(this.keycloak.tokenParsed?.['preferred_username']); + this.keycloak.onTokenExpired = () => this.keycloak.updateToken(30).catch(() => this.logout()); + } + } catch { + console.error('Échec de l\'initialisation Keycloak'); + } + } + + login(): Promise { + return this.keycloak.login(); + } + + logout(): Promise { + return this.keycloak.logout({ redirectUri: window.location.origin }); + } + + isLoggedIn(): boolean { + return this.keycloak.authenticated ?? false; + } +} diff --git a/src/assets/silent-check-sso.html b/src/assets/silent-check-sso.html new file mode 100644 index 0000000..5357587 --- /dev/null +++ b/src/assets/silent-check-sso.html @@ -0,0 +1,8 @@ + + + + + + From e946436a42559ed97c03c7016925a8684c3a8212 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 07:16:19 +0200 Subject: [PATCH 18/22] Login / Logout --- src/app/auth/keycloak.service.ts | 2 -- src/app/menu/menu.css | 29 +++++++++++++++++++++++------ src/app/menu/menu.html | 31 ++++++++++++++++--------------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/app/auth/keycloak.service.ts b/src/app/auth/keycloak.service.ts index 9517750..27d4294 100644 --- a/src/app/auth/keycloak.service.ts +++ b/src/app/auth/keycloak.service.ts @@ -15,8 +15,6 @@ export class KeycloakService { async init(): Promise { try { const authenticated = await this.keycloak.init({ - onLoad: 'check-sso', - silentCheckSsoRedirectUri: `${window.location.origin}/assets/silent-check-sso.html`, pkceMethod: 'S256', }); this.isAuthenticated.set(authenticated); diff --git a/src/app/menu/menu.css b/src/app/menu/menu.css index e6e172a..788daec 100644 --- a/src/app/menu/menu.css +++ b/src/app/menu/menu.css @@ -34,6 +34,11 @@ letter-spacing: -0.02em; } +.sidebar-version { + font-size: 0.72rem; + color: #9ca3af; +} + .sidebar-nav { display: flex; flex-direction: column; @@ -64,6 +69,24 @@ font-weight: 600; } +.sidebar-login { + margin-top: auto; + width: 100%; + padding: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: #ffffff; + background: #2563eb; + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: background 0.1s; +} + +.sidebar-login:hover { + background: #1d4ed8; +} + .sidebar-user { display: flex; flex-direction: column; @@ -101,9 +124,3 @@ border-color: #fca5a5; } -.sidebar-footer { - padding: 0 0.5rem; - font-size: 0.72rem; - color: #9ca3af; - text-align: center; -} diff --git a/src/app/menu/menu.html b/src/app/menu/menu.html index 84b5e77..703fa86 100644 --- a/src/app/menu/menu.html +++ b/src/app/menu/menu.html @@ -25,27 +25,28 @@ Bonsai + v{{ version }} - - @if (keycloak.isAuthenticated()) { + + + } @else { + } - -
v{{ version }}
From 14156a23fbe15e07daf136d271e778d5aa757fac Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 09:27:01 +0200 Subject: [PATCH 19/22] auth --- .claude/settings.local.json | 11 ++ src/app/app.config.ts | 3 + src/app/auth/auth.interceptor.ts | 18 ++ src/app/auth/keycloak.service.ts | 9 + .../issues/issue-comments/issue-comments.ts | 12 +- src/app/issues/issue-detail/issue-detail.ts | 52 +++--- src/app/issues/issues-api.service.ts | 27 +++ src/app/issues/issues.store.ts | 163 +++++------------- src/app/issues/issues.ts | 9 +- 9 files changed, 154 insertions(+), 150 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/app/auth/auth.interceptor.ts create mode 100644 src/app/issues/issues-api.service.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1d72b43 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npm list *)", + "Bash(./node_modules/.bin/ng test *)", + "Bash(npx ng *)" + ] + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 164478f..3dbb622 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,13 +1,16 @@ import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { KeycloakService } from './auth/keycloak.service'; +import { authInterceptor } from './auth/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), provideAppInitializer(() => inject(KeycloakService).init()), ], }; diff --git a/src/app/auth/auth.interceptor.ts b/src/app/auth/auth.interceptor.ts new file mode 100644 index 0000000..dfd07e3 --- /dev/null +++ b/src/app/auth/auth.interceptor.ts @@ -0,0 +1,18 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { from, switchMap } from 'rxjs'; +import { KeycloakService } from './keycloak.service'; +import { API_BASE_URL } from '../issues/issues-api.service'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + if (!req.url.startsWith(API_BASE_URL)) { + return next(req); + } + const keycloak = inject(KeycloakService); + return from(keycloak.getToken()).pipe( + switchMap((token) => { + if (!token) return next(req); + return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); + }), + ); +}; diff --git a/src/app/auth/keycloak.service.ts b/src/app/auth/keycloak.service.ts index 27d4294..30e56c4 100644 --- a/src/app/auth/keycloak.service.ts +++ b/src/app/auth/keycloak.service.ts @@ -38,4 +38,13 @@ export class KeycloakService { isLoggedIn(): boolean { return this.keycloak.authenticated ?? false; } + + async getToken(): Promise { + try { + await this.keycloak.updateToken(30); + return this.keycloak.token; + } catch { + return undefined; + } + } } diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index a916e52..a092319 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -39,14 +39,14 @@ export class IssueComments { }); } - protected addComment(): void { + protected async addComment(): Promise { const text = this.newCommentText.trim(); if (!text) return; const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1; const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null }; - this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] }); + await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] }); this.newCommentText = ''; } @@ -55,7 +55,7 @@ export class IssueComments { this.editingCommentText = comment.text; } - protected saveEditComment(): void { + protected async saveEditComment(): Promise { const text = this.editingCommentText.trim(); if (!text || this.editingCommentId === null) return; const issue = this.issuesStore.getById(this.issueId()); @@ -63,7 +63,7 @@ export class IssueComments { const updatedComments = issue.comments.map((c) => c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c, ); - this.issuesStore.upsert({ ...issue, comments: updatedComments }); + await this.issuesStore.upsert({ ...issue, comments: updatedComments }); this.editingCommentId = null; this.editingCommentText = ''; } @@ -73,9 +73,9 @@ export class IssueComments { this.editingCommentText = ''; } - protected deleteComment(id: number): void { + protected async deleteComment(id: number): Promise { const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; - this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) }); + await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) }); } } diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 0fac2d0..c37b335 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -25,6 +25,16 @@ export class IssueDetail { protected moreMenuOpen = false; constructor() { + const idParam = this.route.snapshot.paramMap.get('id'); + const safeId = Number(idParam ?? 0); + + this.issuesStore.load().then(() => { + if (safeId) { + const found = this.issuesStore.getById(safeId); + if (found) this.issue = { ...found }; + } + }); + this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { const id = Number(params.get('id')); if (!id || isNaN(id)) return; @@ -38,6 +48,7 @@ export class IssueDetail { } }); } + protected showAddDependency = false; protected selectedCandidateId: number | null = null; protected editingDescription = false; @@ -86,18 +97,18 @@ export class IssueDetail { this.selectedCandidateId = null; } - protected confirmAddDependency(): void { + protected async confirmAddDependency(): Promise { if (this.selectedCandidateId !== null) { this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId]; - this.saveIssue(); + await this.saveIssue(); } this.showAddDependency = false; this.selectedCandidateId = null; } - protected removeDependency(id: number): void { + protected async removeDependency(id: number): Promise { this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id); - this.saveIssue(); + await this.saveIssue(); } protected get estimatedTimeValue(): number | null { @@ -146,11 +157,11 @@ export class IssueDetail { this.newIssueName = ''; } - protected confirmCreateInEpic(): void { + protected async confirmCreateInEpic(): Promise { const name = this.newIssueName.trim(); if (!name) return; - this.issuesStore.upsert({ - id: this.issuesStore.getNextId(), + await this.issuesStore.upsert({ + id: 0, type: 'Story', assignee: '', epic: this.issue.name, @@ -178,11 +189,11 @@ export class IssueDetail { this.selectedEpicCandidateId = null; } - protected confirmAddToEpic(): void { + protected async confirmAddToEpic(): Promise { if (this.selectedEpicCandidateId !== null) { const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId); if (target) { - this.issuesStore.upsert({ ...target, epic: this.issue.name }); + await this.issuesStore.upsert({ ...target, epic: this.issue.name }); } } this.showAddToEpic = false; @@ -229,12 +240,13 @@ export class IssueDetail { } } - protected saveIssue(explicit = false): void { + protected async saveIssue(explicit = false): Promise { if (this.isNewIssueRoute && !explicit) return; if (!this.issue.name.trim()) return; - this.issuesStore.upsert(this.issue); + const saved = await this.issuesStore.upsert(this.issue); + this.issue = { ...saved }; if (this.isNewIssueRoute) { - this.router.navigate(['/issues', this.issue.id]); + this.router.navigate(['/issues', saved.id]); } } @@ -242,14 +254,15 @@ export class IssueDetail { this.router.navigate(['/issues']); } - protected deleteIssue(): void { - this.issuesStore.deleteById(this.issue.id); + protected async deleteIssue(): Promise { + await this.issuesStore.deleteById(this.issue.id); this.router.navigate(['/issues']); } - protected updateStatus(status: IssueEntity['status']): void { + protected async updateStatus(status: IssueEntity['status']): Promise { this.issue.status = status; - this.issuesStore.upsert(this.issue); + const saved = await this.issuesStore.upsert(this.issue); + this.issue = { ...saved }; } protected toggleMoreMenu(): void { @@ -262,15 +275,11 @@ export class IssueDetail { private buildIssue(): IssueEntity { const idParam = this.route.snapshot.paramMap.get('id'); - const draftId = this.route.snapshot.queryParamMap.get('draftId'); const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; - const resolvedId = Number(idParam ?? draftId ?? 0); - const safeId = Number.isNaN(resolvedId) ? 0 : resolvedId; - if (isNewIssueRoute) { return { - id: safeId, + id: 0, type: 'Story', assignee: '', epic: '', @@ -286,6 +295,7 @@ export class IssueDetail { }; } + const safeId = Number(idParam ?? 0); const existingIssue = this.issuesStore.getById(safeId); return ( diff --git a/src/app/issues/issues-api.service.ts b/src/app/issues/issues-api.service.ts new file mode 100644 index 0000000..c54c79b --- /dev/null +++ b/src/app/issues/issues-api.service.ts @@ -0,0 +1,27 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { IssueEntity } from './issues.store'; + +export const API_BASE_URL = 'http://localhost:8080'; + +@Injectable({ providedIn: 'root' }) +export class IssuesApiService { + private readonly http = inject(HttpClient); + + getAll(): Observable { + return this.http.get(`${API_BASE_URL}/issues`); + } + + create(issue: Omit): Observable { + return this.http.post(`${API_BASE_URL}/issues`, issue); + } + + update(id: number, issue: IssueEntity): Observable { + return this.http.put(`${API_BASE_URL}/issues/${id}`, issue); + } + + remove(id: number): Observable { + return this.http.delete(`${API_BASE_URL}/issues/${id}`); + } +} diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 908c3df..0899917 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -1,6 +1,6 @@ -import { Injectable, signal } from '@angular/core'; - -const ISSUES_STORAGE_KEY = 'bonsai.issues'; +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { IssuesApiService } from './issues-api.service'; export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress'; export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute'; @@ -29,108 +29,60 @@ export type IssueEntity = { progress: number; }; -const DEFAULT_ISSUES: IssueEntity[] = [ - { - id: 1, - type: 'Bug', - assignee: 'Marie', - epic: 'EPIC-UI', - name: 'Bug affichage menu mobile', - dueDate: '2026-06-10', - description: 'Corriger le comportement du menu sur petits ecrans.', - estimatedTime: 8, - dependsOnIds: [], - comments: [], - priority: 'Haute', - status: 'in-progress', - progress: 35, - }, - { - id: 2, - type: 'Study', - assignee: 'Nabil', - epic: 'EPIC-FORM', - name: 'Erreur validation formulaire projet', - dueDate: '2026-06-12', - description: 'Fiabiliser les regles de validation du formulaire projet.', - estimatedTime: 16, - dependsOnIds: [], - comments: [], - priority: 'Moyenne', - status: 'todo', - progress: 20, - }, - { - id: 3, - type: 'Story', - assignee: 'Sonia', - epic: 'EPIC-CONTENT', - name: 'Mise a jour message de bienvenue', - dueDate: '2026-06-18', - description: 'Mettre a jour le wording d accueil selon la charte produit.', - estimatedTime: 4, - dependsOnIds: [], - comments: [], - priority: 'Basse', - status: 'done', - progress: 100, - }, -]; - @Injectable({ providedIn: 'root' }) export class IssuesStore { - private readonly data = signal(DEFAULT_ISSUES); - - constructor() { - const cachedIssues = this.readFromStorage(); - if (cachedIssues) { - this.data.set(cachedIssues.map((issue) => this.normalizeIssue(issue))); - } - } + private readonly api = inject(IssuesApiService); + private readonly data = signal([]); + readonly loading = signal(false); + readonly loaded = signal(false); readonly issues = this.data.asReadonly(); getById(id: number): IssueEntity | undefined { - return this.data().find((issue) => issue.id === id); + return this.data().find((i) => i.id === id); } - getNextId(): number { - const ids = this.data().map((issue) => issue.id); - return ids.length > 0 ? Math.max(...ids) + 1 : 1; + async load(): Promise { + if (this.loaded()) return; + this.loading.set(true); + try { + const issues = await firstValueFrom(this.api.getAll()); + this.data.set(issues.map((i) => this.normalizeIssue(i))); + this.loaded.set(true); + } finally { + this.loading.set(false); + } } - - upsert(issue: IssueEntity): void { - const normalizedIssue = this.normalizeIssue(issue); - - this.data.update((issues) => { - const existingIndex = issues.findIndex((current) => current.id === issue.id); - - if (existingIndex === -1) { - const created = [...issues, normalizedIssue]; - this.persistToStorage(created); - return created; - } - - const updated = [...issues]; - updated[existingIndex] = normalizedIssue; - this.persistToStorage(updated); + async upsert(issue: IssueEntity): Promise { + const normalized = this.normalizeIssue(issue); + if (!normalized.id) { + const { id: _id, ...body } = normalized; + const created = this.normalizeIssue(await firstValueFrom(this.api.create(body))); + this.data.update((issues) => [...issues, created]); + return created; + } else { + const updated = this.normalizeIssue( + await firstValueFrom(this.api.update(normalized.id, normalized)), + ); + this.data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return issues; + const copy = [...issues]; + copy[idx] = updated; + return copy; + }); return updated; - }); + } } - deleteById(id: number): void { - this.data.update((issues) => { - const updated = issues - .filter((issue) => issue.id !== id) - .map((issue) => ({ - ...issue, - dependsOnIds: issue.dependsOnIds.filter((dependencyId) => dependencyId !== id), - })); - - this.persistToStorage(updated); - return updated; - }); + async deleteById(id: number): Promise { + await firstValueFrom(this.api.remove(id)); + this.data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); } private normalizeIssue( @@ -149,31 +101,4 @@ export class IssuesStore { comments: Array.isArray(issue.comments) ? issue.comments : [], } as IssueEntity; } - - private readFromStorage(): IssueEntity[] | null { - if (typeof window === 'undefined') { - return null; - } - - const rawIssues = window.localStorage.getItem(ISSUES_STORAGE_KEY); - if (!rawIssues) { - return null; - } - - try { - const parsed = JSON.parse(rawIssues); - return Array.isArray(parsed) ? (parsed as IssueEntity[]) : null; - } catch { - return null; - } - } - - private persistToStorage(issues: IssueEntity[]): void { - if (typeof window === 'undefined') { - return; - } - - window.localStorage.setItem(ISSUES_STORAGE_KEY, JSON.stringify(issues)); - } } - diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index e5ee32d..4486d2f 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -12,6 +12,10 @@ export class Issues { private readonly router = inject(Router); private readonly issuesStore = inject(IssuesStore); + constructor() { + this.issuesStore.load(); + } + protected readonly issues = this.issuesStore.issues; protected selectedType: IssueEntity['type'] | null = null; @@ -29,10 +33,7 @@ export class Issues { } protected createIssue(): void { - const nextId = this.issuesStore.getNextId(); - this.router.navigate(['/issues/new'], { - queryParams: { draftId: nextId, mode: 'edit' }, - }); + this.router.navigate(['/issues/new']); } protected openIssue(issueId: number): void { From c9f28638156f3799b2d5180ca76ad150da075b0f Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 10:22:28 +0200 Subject: [PATCH 20/22] change auth claud --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1d72b43..97e5598 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(npm test *)", "Bash(npm list *)", "Bash(./node_modules/.bin/ng test *)", - "Bash(npx ng *)" + "Bash(npx ng *)", + "Bash(npm start *)" ] } } From 264d9f14028f3369311fba5a1456b57cbcaaa931 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 11:09:12 +0200 Subject: [PATCH 21/22] update test --- src/app/auth/auth.guard.spec.ts | 47 +++++ src/app/auth/auth.interceptor.spec.ts | 55 +++++ src/app/auth/keycloak.service.spec.ts | 137 ++++++++++++ .../issue-comments/issue-comments.spec.ts | 87 ++++++-- .../issues/issue-detail/issue-detail.spec.ts | 96 ++++++--- src/app/issues/issue-detail/issue-detail.ts | 3 +- src/app/issues/issues.spec.ts | 88 ++++++-- src/app/issues/issues.store.spec.ts | 195 ++++++++++-------- src/app/issues/issues.store.ts | 5 + 9 files changed, 575 insertions(+), 138 deletions(-) create mode 100644 src/app/auth/auth.guard.spec.ts create mode 100644 src/app/auth/auth.interceptor.spec.ts create mode 100644 src/app/auth/keycloak.service.spec.ts diff --git a/src/app/auth/auth.guard.spec.ts b/src/app/auth/auth.guard.spec.ts new file mode 100644 index 0000000..5e80996 --- /dev/null +++ b/src/app/auth/auth.guard.spec.ts @@ -0,0 +1,47 @@ +import { vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { authGuard } from './auth.guard'; +import { KeycloakService } from './keycloak.service'; + +describe('authGuard', () => { + let mockKeycloak: { isLoggedIn: ReturnType; login: ReturnType }; + + beforeEach(() => { + mockKeycloak = { + isLoggedIn: vi.fn().mockReturnValue(true), + login: vi.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [{ provide: KeycloakService, useValue: mockKeycloak }], + }); + }); + + const runGuard = () => + TestBed.runInInjectionContext(() => + authGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + + it('returns true when the user is logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(true); + expect(runGuard()).toBe(true); + }); + + it('returns false when the user is not logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(false); + expect(runGuard()).toBe(false); + }); + + it('calls login() when the user is not logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(false); + runGuard(); + expect(mockKeycloak.login).toHaveBeenCalled(); + }); + + it('does not call login() when the user is already logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(true); + runGuard(); + expect(mockKeycloak.login).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/auth/auth.interceptor.spec.ts b/src/app/auth/auth.interceptor.spec.ts new file mode 100644 index 0000000..a291c6e --- /dev/null +++ b/src/app/auth/auth.interceptor.spec.ts @@ -0,0 +1,55 @@ +import { vi } from 'vitest'; +import { HttpRequest } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of } from 'rxjs'; +import { authInterceptor } from './auth.interceptor'; +import { KeycloakService } from './keycloak.service'; +import { API_BASE_URL } from '../issues/issues-api.service'; + +describe('authInterceptor', () => { + let mockKeycloak: { getToken: ReturnType }; + + beforeEach(() => { + mockKeycloak = { getToken: vi.fn().mockResolvedValue('test-token') }; + + TestBed.configureTestingModule({ + providers: [{ provide: KeycloakService, useValue: mockKeycloak }], + }); + }); + + const intercept = (req: HttpRequest) => { + const captured: HttpRequest[] = []; + const next = vi.fn((r: HttpRequest) => { captured.push(r); return of(null as any); }); + const obs = TestBed.runInInjectionContext(() => authInterceptor(req, next as any)); + return { obs, next, captured }; + }; + + it('skips token logic for requests outside API_BASE_URL', () => { + const req = new HttpRequest('GET', 'http://other.example.com/data'); + const { next } = intercept(req); + expect(mockKeycloak.getToken).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(req); + }); + + it('calls getToken for requests to API_BASE_URL', () => { + const req = new HttpRequest('GET', `${API_BASE_URL}/issues`); + intercept(req); + expect(mockKeycloak.getToken).toHaveBeenCalled(); + }); + + it('adds Authorization header when token is available', async () => { + mockKeycloak.getToken.mockResolvedValue('my-token'); + const req = new HttpRequest('GET', `${API_BASE_URL}/issues`); + const { obs, captured } = intercept(req); + await firstValueFrom(obs); + expect(captured[0].headers.get('Authorization')).toBe('Bearer my-token'); + }); + + it('forwards the request without Authorization header when token is undefined', async () => { + mockKeycloak.getToken.mockResolvedValue(undefined); + const req = new HttpRequest('GET', `${API_BASE_URL}/issues`); + const { obs, captured } = intercept(req); + await firstValueFrom(obs); + expect(captured[0].headers.has('Authorization')).toBe(false); + }); +}); diff --git a/src/app/auth/keycloak.service.spec.ts b/src/app/auth/keycloak.service.spec.ts new file mode 100644 index 0000000..82b514e --- /dev/null +++ b/src/app/auth/keycloak.service.spec.ts @@ -0,0 +1,137 @@ +import { vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { KeycloakService } from './keycloak.service'; + +const mockKc = vi.hoisted(() => ({ + init: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + updateToken: vi.fn(), + token: 'mock-token' as string | undefined, + tokenParsed: { preferred_username: 'testuser' } as Record | undefined, + authenticated: true as boolean | undefined, + onTokenExpired: undefined as (() => void) | undefined, +})); + +vi.mock('keycloak-js', () => ({ default: vi.fn(function () { return mockKc; }) })); + +describe('KeycloakService', () => { + let service: KeycloakService; + + beforeEach(() => { + mockKc.init.mockResolvedValue(true); + mockKc.login.mockResolvedValue(undefined); + mockKc.logout.mockResolvedValue(undefined); + mockKc.updateToken.mockResolvedValue(true); + mockKc.token = 'mock-token'; + mockKc.tokenParsed = { preferred_username: 'testuser' }; + mockKc.authenticated = true; + mockKc.onTokenExpired = undefined; + + vi.clearAllMocks(); + mockKc.init.mockResolvedValue(true); + mockKc.login.mockResolvedValue(undefined); + mockKc.logout.mockResolvedValue(undefined); + mockKc.updateToken.mockResolvedValue(true); + + TestBed.configureTestingModule({}); + service = TestBed.inject(KeycloakService); + }); + + describe('init', () => { + it('sets isAuthenticated to true when authenticated', async () => { + await service.init(); + expect(service.isAuthenticated()).toBe(true); + }); + + it('sets username from tokenParsed when authenticated', async () => { + await service.init(); + expect(service.username()).toBe('testuser'); + }); + + it('registers an onTokenExpired handler when authenticated', async () => { + await service.init(); + expect(mockKc.onTokenExpired).toBeTypeOf('function'); + }); + + it('onTokenExpired calls logout when updateToken fails', async () => { + mockKc.updateToken.mockRejectedValue(new Error('expired')); + await service.init(); + await mockKc.onTokenExpired!(); + expect(mockKc.logout).toHaveBeenCalled(); + }); + + it('sets isAuthenticated to false when not authenticated', async () => { + mockKc.init.mockResolvedValue(false); + await service.init(); + expect(service.isAuthenticated()).toBe(false); + }); + + it('leaves username undefined when not authenticated', async () => { + mockKc.init.mockResolvedValue(false); + await service.init(); + expect(service.username()).toBeUndefined(); + }); + + it('handles init failure gracefully without throwing', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockKc.init.mockRejectedValue(new Error('connection refused')); + await expect(service.init()).resolves.toBeUndefined(); + spy.mockRestore(); + }); + + it('logs an error when init throws', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockKc.init.mockRejectedValue(new Error('fail')); + await service.init(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + }); + + describe('login', () => { + it('delegates to keycloak.login()', async () => { + await service.login(); + expect(mockKc.login).toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('delegates to keycloak.logout() with window.location.origin as redirectUri', async () => { + await service.logout(); + expect(mockKc.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin }); + }); + }); + + describe('isLoggedIn', () => { + it('returns true when keycloak.authenticated is true', () => { + mockKc.authenticated = true; + expect(service.isLoggedIn()).toBe(true); + }); + + it('returns false when keycloak.authenticated is false', () => { + mockKc.authenticated = false; + expect(service.isLoggedIn()).toBe(false); + }); + + it('returns false when keycloak.authenticated is undefined', () => { + mockKc.authenticated = undefined; + expect(service.isLoggedIn()).toBe(false); + }); + }); + + describe('getToken', () => { + it('calls updateToken(30) and returns the token', async () => { + mockKc.token = 'fresh-token'; + const token = await service.getToken(); + expect(mockKc.updateToken).toHaveBeenCalledWith(30); + expect(token).toBe('fresh-token'); + }); + + it('returns undefined when updateToken fails', async () => { + mockKc.updateToken.mockRejectedValue(new Error('session expired')); + const token = await service.getToken(); + expect(token).toBeUndefined(); + }); + }); +}); diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 9ae7810..4bb3bea 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -1,29 +1,92 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { IssueComments } from './issue-comments'; -import { IssuesStore } from '../issues.store'; +import { IssueEntity, IssuesStore } from '../issues.store'; + +const makeIssue = (overrides: Partial = {}): IssueEntity => ({ + id: 1, + type: 'Story', + assignee: '', + epic: '', + name: 'Test Issue', + dueDate: '', + description: '', + estimatedTime: null, + dependsOnIds: [], + comments: [], + priority: 'Moyenne', + status: 'draft', + progress: 0, + ...overrides, +}); + +class FakeIssuesStore { + private _data = signal([makeIssue({ id: 1 })]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + getNextId(): number { + const ids = this._data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: any): Promise { + const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue; + const normalized: IssueEntity = { + ...makeIssue(), + ...rest, + dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : [], + comments: Array.isArray(c) ? c : [], + estimatedTime: et ?? null, + }; + this._data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...issues, normalized]; + const copy = [...issues]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } + + deleteById(id: number): Promise { + this._data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); + return Promise.resolve(); + } +} describe('IssueComments', () => { let component: IssueComments; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [IssueComments], + providers: [{ provide: IssuesStore, useValue: store }], }).compileComponents(); - store = TestBed.inject(IssuesStore); fixture = TestBed.createComponent(IssueComments); component = fixture.componentInstance; fixture.componentRef.setInput('issueId', 1); await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -68,9 +131,9 @@ describe('IssueComments', () => { expect(store.getById(1)?.comments[0].text).toBe('Test comment'); }); - it('clears newCommentText after adding', () => { + it('clears newCommentText after adding', async () => { (component as any).newCommentText = 'Some text'; - (component as any).addComment(); + await (component as any).addComment(); expect((component as any).newCommentText).toBe(''); }); @@ -147,9 +210,9 @@ describe('IssueComments', () => { expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull(); }); - it('resets editing state after saving', () => { + it('resets editing state after saving', async () => { (component as any).editingCommentText = 'Done'; - (component as any).saveEditComment(); + await (component as any).saveEditComment(); expect((component as any).editingCommentId).toBeNull(); expect((component as any).editingCommentText).toBe(''); }); diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 01d22a1..187c461 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { provideRouter } from '@angular/router'; @@ -23,6 +24,63 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ ...overrides, }); +class FakeIssuesStore { + private _data = signal([ + makeIssue({ id: 1, name: 'Issue 1' }), + makeIssue({ id: 2, name: 'Issue 2' }), + makeIssue({ id: 3, name: 'Issue 3' }), + ]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + getNextId(): number { + const ids = this._data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: any): Promise { + const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue; + const normalized: IssueEntity = { + ...makeIssue(), + ...rest, + dependsOnIds: Array.isArray(deps) + ? deps.filter((v: unknown) => typeof v === 'number') + : typeof legacy === 'number' + ? [legacy] + : [], + comments: Array.isArray(c) ? c : [], + estimatedTime: et ?? null, + }; + this._data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...issues, normalized]; + const copy = [...issues]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } + + deleteById(id: number): Promise { + this._data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); + return Promise.resolve(); + } +} + function makeRoute(id = '1', path = 'issues/:id') { return { snapshot: { @@ -37,30 +95,26 @@ function makeRoute(id = '1', path = 'issues/:id') { describe('IssueDetail — existing issue', () => { let component: IssueDetail; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; let router: Router; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: makeRoute('1') }, + { provide: IssuesStore, useValue: store }, ], }).compileComponents(); - store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(IssueDetail); component = fixture.componentInstance; await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -115,7 +169,7 @@ describe('IssueDetail — existing issue', () => { describe('deleteIssue', () => { it('removes the issue and navigates to /issues', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); - (component as any).deleteIssue(); + await (component as any).deleteIssue(); expect(store.getById(1)).toBeUndefined(); expect(spy).toHaveBeenCalledWith(['/issues']); }); @@ -169,9 +223,9 @@ describe('IssueDetail — existing issue', () => { expect((component as any).selectedCandidateId).toBeNull(); }); - it('confirmAddDependency adds the selected id and saves', () => { + it('confirmAddDependency adds the selected id and saves', async () => { (component as any).selectedCandidateId = 2; - (component as any).confirmAddDependency(); + await (component as any).confirmAddDependency(); expect((component as any).issue.dependsOnIds).toContain(2); expect(store.getById(1)?.dependsOnIds).toContain(2); expect((component as any).showAddDependency).toBe(false); @@ -370,9 +424,9 @@ describe('IssueDetail — existing issue', () => { expect(created?.type).toBe('Story'); }); - it('confirmCreateInEpic resets the form', () => { + it('confirmCreateInEpic resets the form', async () => { (component as any).newIssueName = 'Child Issue'; - (component as any).confirmCreateInEpic(); + await (component as any).confirmCreateInEpic(); expect((component as any).showCreateInEpic).toBe(false); expect((component as any).newIssueName).toBe(''); }); @@ -405,9 +459,9 @@ describe('IssueDetail — existing issue', () => { expect((component as any).selectedEpicCandidateId).toBeNull(); }); - it('confirmAddToEpic assigns the epic name to the selected issue', () => { + it('confirmAddToEpic assigns the epic name to the selected issue', async () => { (component as any).selectedEpicCandidateId = 2; - (component as any).confirmAddToEpic(); + await (component as any).confirmAddToEpic(); expect(store.getById(2)?.epic).toBe('My Epic'); expect((component as any).showAddToEpic).toBe(false); }); @@ -436,11 +490,11 @@ describe('IssueDetail — existing issue', () => { describe('IssueDetail — new issue route', () => { let component: IssueDetail; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; let router: Router; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ @@ -456,20 +510,16 @@ describe('IssueDetail — new issue route', () => { paramMap: of(convertToParamMap({})), }, }, + { provide: IssuesStore, useValue: store }, ], }).compileComponents(); - store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(IssueDetail); component = fixture.componentInstance; await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -486,14 +536,14 @@ describe('IssueDetail — new issue route', () => { it('saveIssue without explicit flag does nothing for new route', () => { (component as any).issue.name = 'Draft Name'; const countBefore = store.issues().length; - (component as any).saveIssue(); // explicit = false + (component as any).saveIssue(); expect(store.issues().length).toBe(countBefore); }); it('saveIssue with explicit=true creates the issue and navigates', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).issue.name = 'Brand New Issue'; - (component as any).saveIssue(true); + await (component as any).saveIssue(true); expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true); expect(spy).toHaveBeenCalledWith(['/issues', 10]); }); diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index c37b335..4dc99ae 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -278,8 +278,9 @@ export class IssueDetail { const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; if (isNewIssueRoute) { + const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0); return { - id: 0, + id: draftId, type: 'Story', assignee: '', epic: '', diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 09c39a0..baa25dc 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { provideRouter } from '@angular/router'; @@ -22,30 +23,93 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ ...overrides, }); +class FakeIssuesStore { + private _data = signal([ + makeIssue({ id: 1, name: 'Issue 1', progress: 0 }), + makeIssue({ id: 2, name: 'Issue 2', progress: 0 }), + makeIssue({ id: 3, name: 'Issue 3', progress: 0 }), + ]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + getNextId(): number { + const ids = this._data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: any): Promise { + const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue; + const normalized: IssueEntity = { + type: 'Story', + assignee: '', + epic: '', + name: '', + dueDate: '', + description: '', + estimatedTime: et ?? null, + comments: Array.isArray(c) ? c : [], + priority: 'Moyenne', + status: 'draft', + progress: 0, + ...rest, + dependsOnIds: Array.isArray(deps) + ? deps.filter((v: unknown) => typeof v === 'number') + : typeof legacy === 'number' + ? [legacy] + : [], + }; + this._data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...issues, normalized]; + const copy = [...issues]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } + + deleteById(id: number): Promise { + this._data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); + return Promise.resolve(); + } +} + describe('Issues', () => { let component: Issues; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; let router: Router; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [Issues], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { provide: IssuesStore, useValue: store }, + ], }).compileComponents(); - store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(Issues); component = fixture.componentInstance; await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -65,7 +129,6 @@ describe('Issues', () => { it('returns empty array when no issues match the selected type', () => { (component as any).selectedType = 'Epic'; const filtered: IssueEntity[] = (component as any).filteredIssues; - // Default store has no Epics, so this should be empty expect(filtered.every((i) => i.type === 'Epic')).toBe(true); }); }); @@ -97,13 +160,10 @@ describe('Issues', () => { }); describe('createIssue', () => { - it('navigates to /issues/new with a draftId query param', async () => { + it('navigates to /issues/new', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).createIssue(); - expect(spy).toHaveBeenCalledWith( - ['/issues/new'], - expect.objectContaining({ queryParams: expect.objectContaining({ mode: 'edit' }) }), - ); + expect(spy).toHaveBeenCalledWith(['/issues/new']); }); }); diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts index 6cdf313..fe02fd6 100644 --- a/src/app/issues/issues.store.spec.ts +++ b/src/app/issues/issues.store.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { IssueEntity, IssuesStore } from './issues.store'; +import { API_BASE_URL } from './issues-api.service'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -20,29 +23,59 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ describe('IssuesStore', () => { let store: IssuesStore; + let httpMock: HttpTestingController; + + const loadWith = async (issues: IssueEntity[]) => { + const p = store.load(); + httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues); + await p; + }; beforeEach(() => { - localStorage.clear(); - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); store = TestBed.inject(IssuesStore); + httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { - localStorage.clear(); + httpMock.verify(); }); it('should be created', () => { expect(store).toBeTruthy(); }); - it('should load default issues when localStorage is empty', () => { - expect(store.issues().length).toBeGreaterThan(0); + describe('load', () => { + it('populates issues from the API', async () => { + await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]); + expect(store.issues().length).toBe(2); + }); + + it('sets loading to true during load and false after', async () => { + const p = store.load(); + expect(store.loading()).toBe(true); + httpMock.expectOne(`${API_BASE_URL}/issues`).flush([]); + await p; + expect(store.loading()).toBe(false); + expect(store.loaded()).toBe(true); + }); + + it('does not reload if already loaded', async () => { + await loadWith([]); + await store.load(); + httpMock.expectNone(`${API_BASE_URL}/issues`); + }); }); describe('getById', () => { + beforeEach(async () => { + await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]); + }); + it('returns the issue with the given id', () => { - const issue = store.getById(1); - expect(issue?.id).toBe(1); + expect(store.getById(1)?.id).toBe(1); }); it('returns undefined for an unknown id', () => { @@ -51,122 +84,108 @@ describe('IssuesStore', () => { }); describe('getNextId', () => { - it('returns max id + 1', () => { - const ids = store.issues().map((i) => i.id); - const expectedNext = Math.max(...ids) + 1; - expect(store.getNextId()).toBe(expectedNext); + it('returns max id + 1', async () => { + await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 5 }), makeIssue({ id: 3 })]); + expect(store.getNextId()).toBe(6); }); - it('returns 1 when there are no issues', () => { - store.deleteById(1); - store.deleteById(2); - store.deleteById(3); + it('returns 1 when there are no issues', async () => { + await loadWith([]); expect(store.getNextId()).toBe(1); }); }); describe('upsert', () => { - it('adds a new issue when the id does not exist', () => { - const before = store.issues().length; - store.upsert(makeIssue({ id: 999 })); - expect(store.issues().length).toBe(before + 1); - expect(store.getById(999)?.name).toBe('Test Issue'); + beforeEach(async () => { + await loadWith([makeIssue({ id: 1, name: 'Existing' }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]); }); - it('updates an existing issue', () => { - store.upsert(makeIssue({ id: 1, name: 'Updated Name' })); + it('creates a new issue via POST when id is 0', async () => { + const before = store.issues().length; + const p = store.upsert(makeIssue({ id: 0, name: 'New Issue' })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 999, name: 'New Issue' })); + await p; + expect(store.issues().length).toBe(before + 1); + expect(store.getById(999)?.name).toBe('New Issue'); + }); + + it('updates an existing issue via PUT', async () => { + const p = store.upsert(makeIssue({ id: 1, name: 'Updated Name' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, name: 'Updated Name' })); + await p; expect(store.getById(1)?.name).toBe('Updated Name'); expect(store.issues().filter((i) => i.id === 1).length).toBe(1); }); - it('persists the issue list to localStorage', () => { - store.upsert(makeIssue({ id: 999 })); - const raw = localStorage.getItem('bonsai.issues'); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw!); - expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(true); - }); - - it('normalizes legacy dependsOnId (single number) to dependsOnIds array when dependsOnIds is absent', () => { - // dependsOnIds must be omitted (not an array) for the legacy field to take effect - store.upsert({ id: 998, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any); + it('normalizes legacy dependsOnId to dependsOnIds array', async () => { + const issue = { id: 0, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] })); + await p; expect(store.getById(998)?.dependsOnIds).toEqual([1]); }); - it('filters non-number values from dependsOnIds', () => { - store.upsert({ ...makeIssue({ id: 997 }), dependsOnIds: [1, 'two', null] } as any); + it('filters non-number values from dependsOnIds', async () => { + const issue = { ...makeIssue({ id: 0 }), dependsOnIds: [1, 'two', null] } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] })); + await p; expect(store.getById(997)?.dependsOnIds).toEqual([1]); }); - it('ensures comments is always an array when missing', () => { - store.upsert({ ...makeIssue({ id: 996 }), comments: undefined } as any); + it('ensures comments is always an array when missing', async () => { + const issue = { ...makeIssue({ id: 0 }), comments: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 996, comments: [] })); + await p; expect(store.getById(996)?.comments).toEqual([]); }); - it('sets default type to Story when type is missing', () => { - store.upsert({ ...makeIssue({ id: 995 }), type: undefined } as any); + it('sets default type to Story when type is missing', async () => { + const issue = { ...makeIssue({ id: 0 }), type: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 995, type: 'Story' })); + await p; expect(store.getById(995)?.type).toBe('Story'); }); - it('sets estimatedTime to null when missing', () => { - store.upsert({ ...makeIssue({ id: 994 }), estimatedTime: undefined } as any); + it('sets estimatedTime to null when missing in API response', async () => { + const issue = { ...makeIssue({ id: 0 }), estimatedTime: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined }); + await p; expect(store.getById(994)?.estimatedTime).toBeNull(); }); }); describe('deleteById', () => { - it('removes the issue from the store', () => { - store.upsert(makeIssue({ id: 999 })); - store.deleteById(999); - expect(store.getById(999)).toBeUndefined(); + beforeEach(async () => { + await loadWith([ + makeIssue({ id: 1 }), + makeIssue({ id: 100 }), + makeIssue({ id: 101, dependsOnIds: [100] }), + ]); }); - it('removes the deleted id from dependsOnIds of other issues', () => { - store.upsert(makeIssue({ id: 100 })); - store.upsert(makeIssue({ id: 101, dependsOnIds: [100] })); - store.deleteById(100); + it('removes the issue from the store', async () => { + const p = store.deleteById(1); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null); + await p; + expect(store.getById(1)).toBeUndefined(); + }); + + it('removes the deleted id from dependsOnIds of other issues', async () => { + const p = store.deleteById(100); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/100` }).flush(null); + await p; expect(store.getById(101)?.dependsOnIds).toEqual([]); }); - it('persists the updated list to localStorage', () => { - store.upsert(makeIssue({ id: 999 })); - store.deleteById(999); - const raw = localStorage.getItem('bonsai.issues'); - const parsed = JSON.parse(raw!); - expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(false); - }); - }); - - describe('localStorage persistence', () => { - it('loads issues from localStorage on construction', () => { - const saved: IssueEntity[] = [makeIssue({ id: 42, name: 'From storage' })]; - localStorage.setItem('bonsai.issues', JSON.stringify(saved)); - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); - const freshStore = TestBed.inject(IssuesStore); - - expect(freshStore.getById(42)?.name).toBe('From storage'); - }); - - it('falls back to defaults when localStorage contains invalid JSON', () => { - localStorage.setItem('bonsai.issues', 'not-valid-json'); - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); - const freshStore = TestBed.inject(IssuesStore); - - expect(freshStore.issues().length).toBeGreaterThan(0); - }); - - it('falls back to defaults when localStorage contains a non-array', () => { - localStorage.setItem('bonsai.issues', '{"key":"value"}'); - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); - const freshStore = TestBed.inject(IssuesStore); - - expect(freshStore.issues().length).toBeGreaterThan(0); + it('does not affect issues with unrelated dependsOnIds', async () => { + const p = store.deleteById(1); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null); + await p; + expect(store.getById(101)?.dependsOnIds).toEqual([100]); }); }); }); diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 0899917..c6bfb19 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -42,6 +42,11 @@ export class IssuesStore { return this.data().find((i) => i.id === id); } + getNextId(): number { + const ids = this.data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + async load(): Promise { if (this.loaded()) return; this.loading.set(true); From 3f4fa0cc371404f3b6453008df285aa08b1a4d18 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 13:52:33 +0200 Subject: [PATCH 22/22] version auto au merge dans main --- .gitea/workflows/release.yml | 53 +++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b1757b3..f6e0535 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,34 +1,37 @@ name: Release + on: - release: - types: [published] + push: + branches: + - main + jobs: - docker: - name: Build & push Docker image + bump-version: runs-on: ubuntu-latest + permissions: + contents: write + steps: - name: Checkout - uses: https://github.com/actions/checkout@v4 - - - name: Set up Docker Buildx - uses: https://github.com/docker/setup-buildx-action@v3 - - - name: Login to Gitea container registry - uses: https://github.com/docker/login-action@v3 + uses: actions/checkout@v4 with: - registry: git.goutailler-olivier.com - username: ${{ gitea.actor }} - password: ${{ secrets.RELEASE_TOKEN }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - - name: Set lowercase repo name - id: repo - run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - - - name: Build and push - uses: https://github.com/docker/build-push-action@v6 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - context: . - push: true - tags: | - git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:${{ gitea.ref_name }} - git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:latest + node-version: '22' + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump patch version + run: npm version patch -m "chore: bump version to %s [skip ci]" + + - name: Push version commit and tag + run: | + git push + git push --tags diff --git a/package.json b/package.json index 5f2c9b6..a9dbc82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bonsai-webapp", - "version": "0.1.0", + "version": "0.1.5", "scripts": { "ng": "ng", "start": "ng serve",