Repository: in2workspace/in2-issuer-ui
Pull request #78: Button credential signature
Author: login: polcaparrosin2
Created: 2025-02-19T10:22:11Z | Merged: 2025-03-27T13:46:30Z



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee57d685..d980a873 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.10.0](https://github.com/in2workspace/in2-issuer-ui/releases/tag/v1.10.0)
+### Added
+- Added button to sign credential when sync flux fails
+### Fixed
+- Small fixes
+
 ## [1.9.0](https://github.com/in2workspace/in2-issuer-ui/releases/tag/v1.9.0)
 ### Added
 - Access by role and policies.
diff --git a/package.json b/package.json
index ccc0da6f..d89b26a9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "in2-issuer-ui",
-  "version": "1.9.0", 
+  "version": "1.10.0",
   "scripts": {
     "ng": "ng",
     "start": "ng serve",
diff --git a/src/app/core/services/credential-procedure.service.spec.ts b/src/app/core/services/credential-procedure.service.spec.ts
index 16aed2f0..5496a664 100644
--- a/src/app/core/services/credential-procedure.service.spec.ts
+++ b/src/app/core/services/credential-procedure.service.spec.ts
@@ -26,6 +26,7 @@ describe('CredentialProcedureService', () => {
   const proceduresURL = `${environment.base_url}${environment.procedures}`;
   const notificationUrl = `${environment.base_url}${environment.notification}`;
   const credentialOfferUrl = `${environment.base_url}${environment.credential_offer_url}`;
+  const signCredentialUrl = `${environment.base_url}${environment.sign_credential_url}`;
 
   beforeEach(() => {
     TestBed.configureTestingModule({
@@ -185,7 +186,19 @@ describe('CredentialProcedureService', () => {
     req.flush({});
   });
 
-  it('should handle error when sending reminder', () => {
+  it('should sign credential successfully', () => {
+    const procedureId = '1';
+
+    service.signCredential(procedureId).subscribe(data => {
+      expect(data).toBeTruthy();
+    });
+
+    const req = httpMock.expectOne(`${signCredentialUrl}/${procedureId}`);
+    expect(req.request.method).toBe('POST');
+    req.flush({});
+  });
+
+  it('should handle error when sending reminder or signing credential', () => {
     const procedureId = '1';
     const errorResponse = new HttpErrorResponse({
       error: '500 error',
@@ -200,8 +213,21 @@ describe('CredentialProcedureService', () => {
       }
     );
 
-    const req = httpMock.expectOne(`${notificationUrl}/${procedureId}`);
-    req.flush('500 error', errorResponse);
+    service.signCredential(procedureId).subscribe(
+      data => fail('should have failed with 500 error'),
+      (error: string) => {
+        expect(error).toContain('Server-side error: 500');
+      }
+    );
+
+    const requests = httpMock.match(() => true);
+
+    expect(requests.length).toBeGreaterThanOrEqual(2);
+
+    requests.forEach((req) => {
+      expect(req.request.method).toBe('POST');
+      req.flush('500 error', errorResponse);
+    });
   });
 
   describe('Get credential offer by transaction code', () => {
diff --git a/src/app/core/services/credential-procedure.service.ts b/src/app/core/services/credential-procedure.service.ts
index 41eb1eed..9f08f478 100644
--- a/src/app/core/services/credential-procedure.service.ts
+++ b/src/app/core/services/credential-procedure.service.ts
@@ -8,6 +8,7 @@ import { ProcedureResponse } from '../models/dto/procedure-response.dto';
 import { LearCredentialEmployeeDataDetail } from '../models/dto/lear-credential-employee-data-detail.dto';
 import { CredentialOfferResponse } from '../models/dto/credential-offer-response';
 import { LEARCredentialEmployeeDataNormalizer } from '../models/entity/lear-credential-employee-data-normalizer';
+import { LEARCredentialEmployee } from '../models/entity/lear-credential-employee.entity';
 
 @Injectable({
   providedIn: 'root'
@@ -18,6 +19,8 @@ export class CredentialProcedureService {
   private readonly organizationProcedures = `${environment.base_url}${environment.procedures}`;
   private readonly credentialOfferUrl = `${environment.base_url}${environment.credential_offer_url}`;
   private readonly notificationProcedure = `${environment.base_url}${environment.notification}`;
+  private readonly signCredentialUrl = `${environment.base_url}${environment.sign_credential_url}`;
+
   private readonly http = inject(HttpClient);
   private readonly normalizer = new LEARCredentialEmployeeDataNormalizer();
 
@@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
-        // Normalize the vc part which is of type LEARCredentialEmployee
-        const normalizedLearCredentialEmployee = this.normalizer.normalizeLearCredential(learCredentialEmployeeDataDetail.credential.vc);
-        // Rebuild the object, keeping the rest intact
+        const { credential } = learCredentialEmployeeDataDetail;
+        // If vc exists, we normalize it, otherwise we assume that credential is already of the expected type
+        const credentialData = credential.vc 
+          ? credential.vc 
+          : (credential as unknown as LEARCredentialEmployee);
+          
+        // Normalize the part which is of type LEARCredentialEmployee
+        const normalizedCredential = this.normalizer.normalizeLearCredential(credentialData);
+  
         return {
           ...learCredentialEmployeeDataDetail,
           credential: {
-            ...learCredentialEmployeeDataDetail.credential,
-            vc: normalizedLearCredentialEmployee
+            ...credential,
+            vc: normalizedCredential
           }
         } as LearCredentialEmployeeDataDetail;
       }),
@@ -60,6 +69,12 @@ export class CredentialProcedureService {
     );
   }
 
+  public signCredential(procedureId: string): Observable<void> {
+    return this.http.post<void>(`${this.signCredentialUrl}/${procedureId}`, {} ).pipe(
+      catchError(this.handleError)
+    );
+  }
+
   public getCredentialOfferByTransactionCode(transactionCode: string): Observable<CredentialOfferResponse> {
     console.info('Getting credential offer by transaction code: ' + transactionCode);
     return this.http.get<CredentialOfferResponse>(`${this.credentialOfferUrl}/transaction-code/${transactionCode}`).pipe(
diff --git a/src/app/features/credential-detail/credential-detail.component.html b/src/app/features/credential-detail/credential-detail.component.html
index 8007b098..1620326c 100644
--- a/src/app/features/credential-detail/credential-detail.component.html
+++ b/src/app/features/credential-detail/credential-detail.component.html
@@ -8,6 +8,7 @@
     [mandator]="credential!.vc.credentialSubject.mandate.mandator"
     [credentialStatus]="credentialStatus!"
     (sendReminder)="openSendReminderDialog()"
+    (signCredential)="openSignCredentialDialog()"
     [viewMode]="'detail'"
   ></app-form-credential>
 }
\ No newline at end of file
diff --git a/src/app/features/credential-detail/credential-detail.component.spec.ts b/src/app/features/credential-detail/credential-detail.component.spec.ts
index 60d6e483..b9eee79c 100644
--- a/src/app/features/credential-detail/credential-detail.component.spec.ts
+++ b/src/app/features/credential-detail/credential-detail.component.spec.ts
@@ -108,6 +108,7 @@ describe('CredentialDetailComponent', () => {
   let mockCredentialProcedureService: {
     getCredentialProcedureById: jest.Mock;
     sendReminder: jest.Mock;
+    signCredential: jest.Mock;
   };
   let dialogService: {
     openDialog: jest.Mock,
@@ -132,7 +133,8 @@ describe('CredentialDetailComponent', () => {
   beforeEach(async () => {
     mockCredentialProcedureService = {
       getCredentialProcedureById: jest.fn(),
-      sendReminder: jest.fn()
+      sendReminder: jest.fn(),
+      signCredential: jest.fn()
     };
     dialogService = {
       openDialog: jest.fn(),
@@ -302,4 +304,86 @@ describe('CredentialDetailComponent', () => {
     tick();
   }));
 
+  it('should open a dialog with correct data and callback', () => {
+    jest.spyOn(translateService, 'instant').mockImplementation((key: any) => {
+      if (key === 'credentialDetail.signCredentialConfirm.title') {
+        return 'Mock Title';
+      }
+      if (key === 'credentialDetail.signCredentialConfirm.message') {
+        return 'Mock Message';
+      }
+      return key;
+    });
+  
+    const signCredentialSpy = jest.spyOn(component, 'signCredential').mockReturnValue(of(true));
+  
+    component.openSignCredentialDialog();
+  
+    const expectedDialogData: DialogData = {
+      title: 'Mock Title',
+      message: 'Mock Message',
+      confirmationType: 'async',
+      status: 'default'
+    };
+    expect(dialogService.openDialogWithCallback).toHaveBeenCalledWith(
+      expectedDialogData,
+      expect.any(Function)
+    );
+  
+    const callback = dialogService.openDialogWithCallback.mock.calls[0][1];
+    callback().subscribe();
+  
+    expect(signCredentialSpy).toHaveBeenCalled();
+  });
+
+  it('should return EMPTY and log an error if credentialId is not defined', () => {
+    component.credentialId = null; 
+  
+    const consoleErrorSpy = jest.spyOn(console, 'error');
+  
+    const result = component.signCredential();
+  
+    result.subscribe({
+      complete: () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWith('No credential id.');
+        expect(mockCredentialProcedureService.signCredential).not.toHaveBeenCalled();
+        expect(dialogService.openDialogWithCallback).not.toHaveBeenCalled();
+        expect(router.navigate).not.toHaveBeenCalled();
+      },
+    });
+  });
+  
+  it('should sign credential, open success dialog, navigate, and reload page if credentialId is defined', fakeAsync(() => {
+    component.credentialId = '123';
+  
+    const signCredentialSpy = mockCredentialProcedureService.signCredential.mockReturnValue(of(null));
+    const dialogRef = {
+      afterClosed: jest.fn().mockReturnValue(of(true)),
+    };
+    jest.spyOn(dialogService, 'openDialog').mockReturnValue(dialogRef as any);
+    const routerNavigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true);
+    const locationReloadSpy = jest.spyOn(location, 'reload').mockImplementation(() => {});
+  
+    const result = component.signCredential();
+  
+    tick();
+    result.subscribe({
+      complete: () => {
+        expect(signCredentialSpy).toHaveBeenCalledWith('123');
+  
+        const expectedDialogData: DialogData = {
+          title: translateService.instant("credentialDetail.signCredentialSuccess.title"),
+          message: translateService.instant("credentialDetail.signCredentialSuccess.message"),
+          confirmationType: 'none',
+          status: 'default'
+        };
+        expect(dialogService.openDialog).toHaveBeenCalledWith(expectedDialogData);
+  
+        expect(routerNavigateSpy).toHaveBeenCalledWith(['/organization/credentials']);
+        expect(locationReloadSpy).toHaveBeenCalled();
+      },
+    });
+    tick();
+  }));
+
 });
diff --git a/src/app/features/credential-detail/credential-detail.component.ts b/src/app/features/credential-detail/credential-detail.component.ts
index 51b2a8da..5dab2e81 100644
--- a/src/app/features/credential-detail/credential-detail.component.ts
+++ b/src/app/features/credential-detail/credential-detail.component.ts
@@ -78,26 +78,45 @@ export class CredentialDetailComponent implements OnInit {
 
   }
 
-  public sendReminder(): Observable<boolean>{
+  public openSignCredentialDialog(): void {
+
+    const dialogData: DialogData = {
+      title: this.translate.instant("credentialDetail.signCredentialConfirm.title"),
+      message: this.translate.instant("credentialDetail.signCredentialConfirm.message"),
+      confirmationType: 'async',
+      status: 'default'
+    };
+
+    const signCredentialAfterConfirm = (): Observable<boolean> => {
+      return this.signCredential();
+    }
+    
+    this.dialog.openDialogWithCallback(dialogData, signCredentialAfterConfirm);
+
+  }
+
+  private executeCredentialAction(
+    action: (credentialId: string) => Observable<void>,
+    titleKey: string,
+    messageKey: string
+  ): Observable<boolean> {
     const credentialId = this.credentialId;
-    if (!credentialId){
+    if (!credentialId) {
       console.error('No credential id.');
       return EMPTY;
     }
-
-    return this.credentialProcedureService.sendReminder(credentialId)
-    // open success dialog and navigate to credentials
-    .pipe(
+  
+    return action(credentialId).pipe(
       switchMap(() => {
-            const dialogData: DialogData = {
-              title: this.translate.instant("credentialDetail.sendReminderSuccess.title"),
-              message: this.translate.instant("credentialDetail.sendReminderSuccess.message"),
-              confirmationType: 'none',
-              status: 'default'
-            };
-
-            const dialogRef = this.dialog.openDialog(dialogData);
-            return dialogRef.afterClosed();
+        const dialogData: DialogData = {
+          title: this.translate.instant(titleKey),
+          message: this.translate.instant(messageKey),
+          confirmationType: 'none',
+          status: 'default'
+        };
+  
+        const dialogRef = this.dialog.openDialog(dialogData);
+        return dialogRef.afterClosed();
       }),
       switchMap(()  =>
         from(this.router.navigate(['/organization/credentials']))
@@ -106,4 +125,20 @@ export class CredentialDetailComponent implements OnInit {
     );
   }
 
+  public sendReminder(): Observable<boolean> {
+    return this.executeCredentialAction(
+      (credentialId) => this.credentialProcedureService.sendReminder(credentialId),
+      "credentialDetail.sendReminderSuccess.title",
+      "credentialDetail.sendReminderSuccess.message"
+    );
+  }
+  
+  public signCredential(): Observable<boolean> {
+    return this.executeCredentialAction(
+      (credentialId) => this.credentialProcedureService.signCredential(credentialId),
+      "credentialDetail.signCredentialSuccess.title",
+      "credentialDetail.signCredentialSuccess.message"
+    );
+  }
+  
 }
diff --git a/src/app/shared/components/form-credential/form-credential.component.html b/src/app/shared/components/form-credential/form-credential.component.html
index c1d8dd58..5b717f9f 100644
--- a/src/app/shared/components/form-credential/form-credential.component.html
+++ b/src/app/shared/components/form-credential/form-credential.component.html
@@ -355,6 +355,8 @@ <h2 class="mandator-title">Mandator</h2>
               <ng-template #detailButtons>
                 <!-- SEND REMINDER BUTTON -->
                 <ng-container *ngTemplateOutlet="sendReminderButton"></ng-container>
+          <!-- SIGN BUTTON -->
+          <ng-container *ngTemplateOutlet="signCredentialButton"></ng-container>
               </ng-template>
             </div>
           </form>
@@ -381,26 +383,26 @@ <h5>{{ "mandator.country" | translate }}</h5>
             <p>{{ mandator.country }}</p>
           </mat-card-content>
         </div>
-      } 
-      @if(issuer.serialNumber!=''){
-        <div class="mandator-container issuer-details">
-          <h2>Issuer</h2>
-          <mat-card-content>
-            <h5 class="margin-top-none">{{ "issuer.name" | translate }}</h5>
-            <p>{{ issuer.commonName }}</p>
-            <h5>{{ "issuer.email" | translate }}</h5>
-            <p>{{ issuer.emailAddress }}</p>
-            <h5>{{ "issuer.serialNumber" | translate }}</h5>
-            <p>{{ issuer.serialNumber  }}</p>
-            <h5>{{ "issuer.organization" | translate }}</h5>
-            <p>{{ issuer.organization  }}</p>
-            <h5>{{ "issuer.organizationid" | translate }}</h5>
-            <p>{{ issuer.organizationIdentifier }}</p>
-            <h5>{{ "issuer.country" | translate }}</h5>
-            <p>{{ issuer.country }}</p>
-          </mat-card-content>
-        </div>
       }
+      @if (issuer.serialNumber) {
+          <div class="mandator-container issuer-details">
+            <h2>Issuer</h2>
+            <mat-card-content>
+              <h5 class="margin-top-none">{{ "issuer.name" | translate }}</h5>
+              <p>{{ issuer.commonName }}</p>
+              <h5>{{ "issuer.email" | translate }}</h5>
+              <p>{{ issuer.emailAddress }}</p>
+              <h5>{{ "issuer.serialNumber" | translate }}</h5>
+              <p>{{ issuer.serialNumber  }}</p>
+              <h5>{{ "issuer.organization" | translate }}</h5>
+              <p>{{ issuer.organization  }}</p>
+              <h5>{{ "issuer.organizationid" | translate }}</h5>
+              <p>{{ issuer.organizationIdentifier }}</p>
+              <h5>{{ "issuer.country" | translate }}</h5>
+              <p>{{ issuer.country }}</p>
+            </mat-card-content>
+          </div>
+        }
     </div>
   </div>
 </div>
@@ -421,3 +423,20 @@ <h5>{{ "issuer.country" | translate }}</h5>
     </div>
   }
 </ng-template>
+
+<!-- REUSABLE SIGN CREDENTIAL TEMPLATE -->
+<ng-template #signCredentialButton>
+  @if (showSignCredentialButton()) {
+    <div class="btn-sign">
+      <button
+      type="button"
+      mat-raised-button
+      color="primary"
+      (click)="triggerSignCredential()"
+      [disabled]="isLoading$|async"
+      >
+        {{ "credentialDetail.signCredentialConfirm.title" | translate }}
+      </button>
+    </div>
+  }
+</ng-template>
diff --git a/src/app/shared/components/form-credential/form-credential.component.spec.ts b/src/app/shared/components/form-credential/form-credential.component.spec.ts
index 8269b722..3d3d7adf 100644
--- a/src/app/shared/components/form-credential/form-credential.component.spec.ts
+++ b/src/app/shared/components/form-credential/form-credential.component.spec.ts
@@ -303,12 +303,45 @@ describe('FormCredentialComponent', () => {
 
   });
 
+  it('should check if signCredential should be shown according to VC status', ()=>{
+    component.viewMode = 'create';
+    component.credentialStatus = 'WITHDRAWN';
+    let showBtn = component.showSignCredentialButton();
+    expect(showBtn).toBe(false);
+
+    component.viewMode = 'detail';
+    showBtn = component.showSignCredentialButton();
+    expect(showBtn).toBe(false);
+
+    component.credentialStatus = 'PEND_DOWNLOAD';
+    showBtn = component.showSignCredentialButton();
+    expect(showBtn).toBe(false);
+
+    component.credentialStatus = 'DRAFT';
+    showBtn = component.showSignCredentialButton();
+    expect(showBtn).toBe(false);
+
+    component.credentialStatus = 'VALID';
+    showBtn = component.showSignCredentialButton();
+    expect(showBtn).toBe(false);
+
+    component.credentialStatus = 'PEND_SIGNATURE';
+    showBtn = component.showSignCredentialButton();
+    expect(showBtn).toBe(true);
+  });
+
   it('should emit sendReminder event when triggerSendReminder is called', () => {
     const spy = jest.spyOn(component.sendReminder, 'emit');
     component.triggerSendReminder();
     expect(spy).toHaveBeenCalled();
   });
 
+  it('should emit signCredential event when triggerSignCredential is called', () => {
+    const spy = jest.spyOn(component.signCredential, 'emit');
+    component.triggerSignCredential();
+    expect(spy).toHaveBeenCalled();
+  });
+
   it('should open submit dialog and call submitCredential', () => {
     const dialogData: DialogData = {
       title: translateService.instant("credentialIssuance.create-confirm-dialog.title"),
diff --git a/src/app/shared/components/form-credential/form-credential.component.ts b/src/app/shared/components/form-credential/form-credential.component.ts
index 13650569..85779809 100644
--- a/src/app/shared/components/form-credential/form-credential.component.ts
+++ b/src/app/shared/components/form-credential/form-credential.component.ts
@@ -64,6 +64,7 @@ import { MatIcon } from '@angular/material/icon';
 export class FormCredentialComponent implements OnInit, OnDestroy {
   @ViewChild('formDirective') public formDirective!: FormGroupDirective;
   @Output() public sendReminder = new EventEmitter<void>();
+  @Output() public signCredential = new EventEmitter<void>();
   @Input({ required: true }) public viewMode: 'create' | 'detail' = 'create';
   @Input() public asSigner: boolean = false;
   @Input() public isDisabled: boolean = false;
@@ -185,6 +186,10 @@ export class FormCredentialComponent implements OnInit, OnDestroy {
     this.sendReminder.emit();
   }
 
+  public triggerSignCredential(): void {
+    this.signCredential.emit();
+  }
+
   public hasSelectedPower(): boolean {
     return this.formService.hasSelectedPower();
   }
@@ -197,6 +202,11 @@ export class FormCredentialComponent implements OnInit, OnDestroy {
     return (this.viewMode === 'detail') && ((this.credentialStatus === 'WITHDRAWN') || (this.credentialStatus === 'DRAFT') || (this.credentialStatus === 'PEND_DOWNLOAD'));
   }
 
+  public showSignCredentialButton(): boolean{
+    return (this.viewMode === 'detail') && (this.credentialStatus === 'PEND_SIGNATURE')
+  }
+
+
   public ngOnDestroy(): void {
     this.formService.reset();
   }
diff --git a/src/assets/env.js b/src/assets/env.js
index 133d9d34..a7d2a49e 100644
--- a/src/assets/env.js
+++ b/src/assets/env.js
@@ -16,12 +16,12 @@
   window["env"]["save_credential"] = "${SAVE_CREDENTIAL}";
   window["env"]["credential_offer_url"] = "${CREDENTIAL_OFFER_URI}";
   window["env"]["notification"] = "${NOTIFICATION}";
+  window["env"]["sign_credential_url"]= "${SIGN_CREDENTIAL_URL}"
   window["env"]["primary"] = "${PRIMARY}";
   window["env"]["primary_contrast"]= "${PRIMARY_CONTRAST}";
   window["env"]["secondary"] = "${SECONDARY}";
   window["env"]["secondary_contrast"]= "${SECONDARY_CONTRAST}";
   window["env"]["logo_src"]= "${LOGO_SRC}"
   window["env"]["favicon_src"]= "${FAVICON_SRC}"
-  //window["env"]["firma_credential"] = "${FIRMA_CREDENTIAL}"; The`firma_credential` variable has been commented out as it was initially intended for the signature functionality,which remains incomplete. This configuration is currently unnecessary for the existing flows but is expected to be reintroduced in the future when the related use case is implemented.
 
 })(this);
diff --git a/src/assets/env.template.js b/src/assets/env.template.js
index 0ef33d82..a748e2ba 100644
--- a/src/assets/env.template.js
+++ b/src/assets/env.template.js
@@ -16,12 +16,11 @@
   window["env"]["save_credential"] = "${SAVE_CREDENTIAL}";
   window["env"]["credential_offer_url"] = "${CREDENTIAL_OFFER_URI}";
   window["env"]["notification"] = "${NOTIFICATION}";
+  window["env"]["sign_credential_url"]= "${SIGN_CREDENTIAL_URL}"
   window["env"]["primary"] = "${PRIMARY}";
   window["env"]["primary_contrast"]= "${PRIMARY_CONTRAST}";
   window["env"]["secondary"] = "${SECONDARY}";
   window["env"]["secondary_contrast"]= "${SECONDARY_CONTRAST}"
   window["env"]["logo_src"]= "${LOGO_SRC}"
   window["env"]["favicon_src"]= "${FAVICON_SRC}"
-  //window["env"]["firma_credential"] = "${FIRMA_CREDENTIAL}"; The`firma_credential` variable has been commented out as it was initially intended for the signature functionality,which remains incomplete. This configuration is currently unnecessary for the existing flows but is expected to be reintroduced in the future when the related use case is implemented.
-
 })(this);
diff --git a/src/assets/i18n/ca.json b/src/assets/i18n/ca.json
index 13325176..7a433e09 100644
--- a/src/assets/i18n/ca.json
+++ b/src/assets/i18n/ca.json
@@ -71,6 +71,14 @@
     "sendReminderSuccess": {
       "title": "Recordatori enviat amb èxit",
       "message": "El recordatori ha estat enviat amb èxit."
+    },
+    "signCredentialConfirm": {
+      "title": "Firmar Credencial",
+      "message": "Estàs segur que vols signar aquesta credencial?"
+    },
+    "signCredentialSuccess": {
+      "title": "Credential firmada",
+      "message": "La credencial ha estat firmada amb èxit."
     }
   },
   "credentialOffer": {
@@ -167,7 +175,7 @@
       "credentialOffer": {
         "invalid-url": "URL no vàlida. No es pot obtenir l'oferta de credencial.",
         "unexpected": "S'ha produït un error inesperat. Torna-ho a intentar més tard o posa't en contacte amb el Representant Legal de la teva empresa per obtenir una nova credencial.",
-        "expired": "Aquesta oferta de credencial ha expirat. Posa't en contacte amb el Representant Legal de la teva empresa per obtenir-ne una de nova."
+        "expired": "Aquesta oferta de credencial no és vàlida. És possible que hagi caducat o que hagi estat substituïda per una nova. Si s'ha enviat una nova oferta, revisi el seu correu electrònic. Si ha caducat, posi's en contacte amb el representant legal de la seva empresa per sol·licitar una nova oferta de credencial."
       },
       "form":{
         "required": "Camp obligatori.",
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index e41763db..8b809408 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -71,6 +71,14 @@
     "sendReminderSuccess": {
       "title": "Reminder sent successfully",
       "message": "The credential offer reminder was sent successfully."
+    }, 
+    "signCredentialConfirm": {
+      "title": "Sign Credential",
+      "message": "Are you sure you want to sign this credential?"
+    },
+    "signCredentialSuccess": {
+      "title": "Credential signed",
+      "message": "The credential was signed successfully."
     }
   },
   "credentialOffer": {
@@ -166,7 +174,7 @@
     "credentialOffer": {
       "invalid-url": "Unexpected error: invalid URL, can't get credential offer.",
       "unexpected": "An unexpected error occurred. Please try again later or contact your company's Legal Representative to get a new credential.",
-      "expired": "This credential offer has expired. Please contact your company's Legal Representative to get a new one."
+      "expired": "This credential offer is invalid. It may have expired or been replaced by a new one. If a new offer was sent, please check your email. If it has expired, contact your company's Legal Representative to request a new credential offer."
     },
     "form":{
       "required": "This field is required.",
diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json
index 12df22e1..6cf9c9c5 100644
--- a/src/assets/i18n/es.json
+++ b/src/assets/i18n/es.json
@@ -71,6 +71,14 @@
     "sendReminderSuccess": {
       "title": "Recordatorio enviado con éxito",
       "message": "El recordatorio ha sido enviado con éxito."
+    },
+    "signCredentialConfirm": {
+      "title": "Firmar credencial",
+      "message": "Está seguro de firmar esta credencial?"
+    },
+    "signCredentialSuccess": {
+      "title": "Credencial firmada",
+      "message": "La credencial ha sido firmada con éxito."
     }
   },
   "credentialOffer": {
@@ -168,7 +176,7 @@
       "credentialOffer": {
         "invalid-url": "URL no válida. No se puede obtener la oferta de credencial.",
         "unexpected": "Se produjo un error inesperado. Inténtalo de nuevo más tarde o contacta con el Representante Legal de tu empresa para obtener una nueva credencial.",
-        "expired": "Esta oferta de credencial ha expirado. Contacta con el Representante Legal de tu empresa para obtener una nueva."
+        "expired": "Esta oferta de credencial no es válida. Es posible que haya caducado o que haya sido sustituida por una nueva. Si se envió una nueva oferta, revise su correo electrónico. Si ha caducado, póngase en contacto con el representante legal de su empresa para solicitar una nueva oferta de credencial."
       },
       "form":{
         "required": "Campo obligatorio.",
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index dadd8a9d..4e20362b 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -19,6 +19,7 @@ export const environment = {
   save_credential: window["env"]["saveCredential"] || '/vci/v1/issuances',
   credential_offer_url: window["env"]["credential_offer_url"] || '/api/v1/credential-offer',
   notification: window["env"]["notification"] || '/api/v1/notifications',
+  sign_credential_url: window["env"]["sign_credential_url"] || '/api/v1/retry-sign-credential',
   customizations:{
     colors:{ 
       primary: window["env"]["primary"] ?? '#2d58a7',
@@ -29,5 +30,4 @@ export const environment = {
     logo_src: window["env"]["logo_src"] || "assets/logos/no-image.png",
     favicon_src: window["env"]["favicon_src"] || "assets/icon/favicon.png"  
   }
-  //firma_credential: window["env"]["firma_credential"] || '/api/v1/sign-credential' The`firma_credential` variable has been commented out as it was initially intended for the signature functionality,which remains incomplete. This configuration is currently unnecessary for the existing flows but is expected to be reintroduced in the future when the related use case is implemented.
 };
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 7a1848ce..fa32669d 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -18,6 +18,7 @@ export const environment = {
   save_credential: '/vci/v1/issuances',
   credential_offer_url: '/api/v1/credential-offer',
   notification: '/api/v1/notifications',
+  sign_credential_url: '/api/v1/retry-sign-credential',
   customizations:{
     colors:{ 
       primary:'#2d58a7',
@@ -28,5 +29,4 @@ export const environment = {
     logo_src:"assets/logos/logo-dome-issuer-reduced.png",
     favicon_src:"assets/icon/favicon.png"  
   }
-  //firma_credential: '/api/v1/sign-credential' The`firma_credential` variable has been commented out as it was initially intended for the signature functionality,which remains incomplete. This configuration is currently unnecessary for the existing flows but is expected to be reintroduced in the future when the related use case is implemented.
 };


Reviews:
user: login: albertrodriguezdev
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-24T13:48:36Z
user: login: albertrodriguezdev
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-27T09:46:46Z
user: login: albertorubioin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:30:10Z
user: login: albertorubioin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:31:47Z
user: login: albertorubioin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:32:51Z
user: login: polcaparrosin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:35:18Z
user: login: polcaparrosin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:37:43Z
user: login: polcaparrosin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:45:07Z
user: login: albertorubioin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:49:30Z
user: login: albertorubioin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:51:26Z
user: login: albertorubioin2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:33:37Z
user: login: albertorubioin2
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:35:09Z
user: login: rubenmodamio
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:46:17Z

Review comments:
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
path: src/app/core/services/credential-procedure.service.ts
user: login: albertorubioin2
body: puedes sustituir el learCredentialEmployeeDataDetail por { credential } y así te ahorras un poco de código :)
created_at: 2025-03-27T12:30:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
-        // Normalize the vc part which is of type LEARCredentialEmployee
-        const normalizedLearCredentialEmployee = this.normalizer.normalizeLearCredential(learCredentialEmployeeDataDetail.credential.vc);
-        // Rebuild the object, keeping the rest intact
+        const { credential } = learCredentialEmployeeDataDetail;
+        // If vc exists, we normalize it, otherwise we assume that credential is already of the expected type
+        const credentialData = credential.vc 
+          ? credential.vc 
+          : (credential as unknown as LEARCredentialEmployee);
path: src/app/core/services/credential-procedure.service.ts
user: login: albertorubioin2
body: y si no lo es como responderá la aplicación? creo que deberíamos de manejarlo, no suponer que estará bien
created_at: 2025-03-27T12:31:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
-        // Normalize the vc part which is of type LEARCredentialEmployee
-        const normalizedLearCredentialEmployee = this.normalizer.normalizeLearCredential(learCredentialEmployeeDataDetail.credential.vc);
-        // Rebuild the object, keeping the rest intact
+        const { credential } = learCredentialEmployeeDataDetail;
+        // If vc exists, we normalize it, otherwise we assume that credential is already of the expected type
+        const credentialData = credential.vc 
+          ? credential.vc 
+          : (credential as unknown as LEARCredentialEmployee);
path: src/app/core/services/credential-procedure.service.ts
user: login: albertorubioin2
body: más que nada que si devuelve un pete aquí sabemos como se va a manejar el pete y el error de la respuesta que va a dar?
created_at: 2025-03-27T12:32:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
-        // Normalize the vc part which is of type LEARCredentialEmployee
-        const normalizedLearCredentialEmployee = this.normalizer.normalizeLearCredential(learCredentialEmployeeDataDetail.credential.vc);
-        // Rebuild the object, keeping the rest intact
+        const { credential } = learCredentialEmployeeDataDetail;
+        // If vc exists, we normalize it, otherwise we assume that credential is already of the expected type
+        const credentialData = credential.vc 
+          ? credential.vc 
+          : (credential as unknown as LEARCredentialEmployee);
path: src/app/core/services/credential-procedure.service.ts
user: login: polcaparrosin2
body: Esto es para soportar las vc learcredentialemployee v1, que tienen el campo vc, ahora en mi implementación ya no tiene ese campo vc, pero es para mantener la retrocompatibilidad
created_at: 2025-03-27T12:35:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
path: src/app/core/services/credential-procedure.service.ts
user: login: polcaparrosin2
body: no se hasta que punto puede afectar esto ya que luego se devuelve mas abajo

return {
          ...learCredentialEmployeeDataDetail,
          credential: {
            ...credential,
            vc: normalizedCredential
          }
        } as LearCredentialEmployeeDataDetail;
        
esto lo implementó rubén, no yo
created_at: 2025-03-27T12:37:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
-        // Normalize the vc part which is of type LEARCredentialEmployee
-        const normalizedLearCredentialEmployee = this.normalizer.normalizeLearCredential(learCredentialEmployeeDataDetail.credential.vc);
-        // Rebuild the object, keeping the rest intact
+        const { credential } = learCredentialEmployeeDataDetail;
+        // If vc exists, we normalize it, otherwise we assume that credential is already of the expected type
+        const credentialData = credential.vc 
+          ? credential.vc 
+          : (credential as unknown as LEARCredentialEmployee);
path: src/app/core/services/credential-procedure.service.ts
user: login: polcaparrosin2
body: la cosa es que antes tampoco se gestionaba, se asumia que siempre iba a tener vc
![Captura de pantalla 2025-03-27 134322](https://github.com/user-attachments/assets/75f833bf-fc8f-47f6-902c-5b99bb3921cc)

en cambio ahora puede tenerlo como antes (cv antiguas) o no, lo cual significara que tendra lo mismo que esperaba antes en vc, lo tiene simplemente en credential. Pero como ya los modelos de datos del LearCredentialEmployeeDataDetail y LEARCredentialEmployee estaban muy robustas, tuve que hacer esto para poder permitir ambas cosas


created_at: 2025-03-27T12:45:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -381,25 +383,27 @@ <h5>{{ "mandator.country" | translate }}</h5>
             <p>{{ mandator.country }}</p>
           </mat-card-content>
         </div>
-      } 
-      @if(issuer.serialNumber!=''){
-        <div class="mandator-container issuer-details">
-          <h2>Issuer</h2>
-          <mat-card-content>
-            <h5 class="margin-top-none">{{ "issuer.name" | translate }}</h5>
-            <p>{{ issuer.commonName }}</p>
-            <h5>{{ "issuer.email" | translate }}</h5>
-            <p>{{ issuer.emailAddress }}</p>
-            <h5>{{ "issuer.serialNumber" | translate }}</h5>
-            <p>{{ issuer.serialNumber  }}</p>
-            <h5>{{ "issuer.organization" | translate }}</h5>
-            <p>{{ issuer.organization  }}</p>
-            <h5>{{ "issuer.organizationid" | translate }}</h5>
-            <p>{{ issuer.organizationIdentifier }}</p>
-            <h5>{{ "issuer.country" | translate }}</h5>
-            <p>{{ issuer.country }}</p>
-          </mat-card-content>
-        </div>
+      }
+      @if(issuer){
path: src/app/shared/components/form-credential/form-credential.component.html
user: login: albertorubioin2
body: este sobra
created_at: 2025-03-27T12:49:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -381,25 +383,27 @@ <h5>{{ "mandator.country" | translate }}</h5>
             <p>{{ mandator.country }}</p>
           </mat-card-content>
         </div>
-      } 
-      @if(issuer.serialNumber!=''){
-        <div class="mandator-container issuer-details">
-          <h2>Issuer</h2>
-          <mat-card-content>
-            <h5 class="margin-top-none">{{ "issuer.name" | translate }}</h5>
-            <p>{{ issuer.commonName }}</p>
-            <h5>{{ "issuer.email" | translate }}</h5>
-            <p>{{ issuer.emailAddress }}</p>
-            <h5>{{ "issuer.serialNumber" | translate }}</h5>
-            <p>{{ issuer.serialNumber  }}</p>
-            <h5>{{ "issuer.organization" | translate }}</h5>
-            <p>{{ issuer.organization  }}</p>
-            <h5>{{ "issuer.organizationid" | translate }}</h5>
-            <p>{{ issuer.organizationIdentifier }}</p>
-            <h5>{{ "issuer.country" | translate }}</h5>
-            <p>{{ issuer.country }}</p>
-          </mat-card-content>
-        </div>
+      }
+      @if(issuer){
+        @if(issuer.serialNumber!='' && issuer.serialNumber!=null && issuer.serialNumber!=undefined){
path: src/app/shared/components/form-credential/form-credential.component.html
user: login: albertorubioin2
body: debería de poder simplificarse ambos ifs por esto @if (issuer?.serialNumber) {

created_at: 2025-03-27T12:51:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,14 +35,20 @@ export class CredentialProcedureService {
       `${this.organizationProcedures}/${procedureId}/credential-decoded`
     ).pipe(
       map(learCredentialEmployeeDataDetail => {
path: src/app/core/services/credential-procedure.service.ts
user: login: albertorubioin2
body: ahh oki cierto
created_at: 2025-03-27T13:33:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-27T13:00:11Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=in2workspace_in2-issuer-ui&pullRequest=78) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=in2workspace_in2-issuer-ui&pullRequest=78&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=in2workspace_in2-issuer-ui&pullRequest=78&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=in2workspace_in2-issuer-ui&pullRequest=78&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [95.2% Coverage on New Code](https://sonarcloud.io/component_measures?id=in2workspace_in2-issuer-ui&pullRequest=78&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=in2workspace_in2-issuer-ui&pullRequest=78&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=in2workspace_in2-issuer-ui&pullRequest=78)


author_association: NONE