Skip to content

Show signature rejection information on the validation page #8162

Description

@vitormattos

Update the validation page to support the signature rejection workflow defined by #7832.

Backend rejection support was added by #8159.

This issue is blocked by #8388, which defines the signer visibility and presentation contract used by the validation page.

#8161 is related, but it is not a technical blocker.

Goal

Make the validation page correctly represent:

  • visible rejection;
  • rejection date and comment when available;
  • hidden signer state without discovering which signer rejected;
  • canceled workflows;
  • workflows that continue after rejection;
  • normal files and envelopes.

The backend is the source of truth for visibility.

The frontend must render the response without reconstructing hidden workflow state.

Backend contract

#8388 introduces a separate presentation state:

displayStatus

Possible values are:

draft
ready_to_sign
signed
rejected
not_signed

displayStatus is not a workflow enum.

The real signer workflow state continues to use SignRequestStatus.

Visible rejection

A visible rejected signer may be returned as:

{
  "status": 3,
  "displayStatus": "rejected",
  "rejection": {
    "rejectedAt": "2026-09-09T12:00:00+00:00"
  }
}

With a visible comment:

{
  "status": 3,
  "displayStatus": "rejected",
  "rejection": {
    "rejectedAt": "2026-09-09T12:00:00+00:00",
    "comment": "I do not agree with this document",
    "commentPrivate": false
  }
}

For a privileged viewer, a private comment may also be returned.

Hidden rejection

When a file contains a rejection that the current viewer cannot see, every unsigned signer in that file receives:

{
  "displayStatus": "not_signed"
}

This may represent a signer whose real state is:

  • DRAFT;
  • ABLE_TO_SIGN;
  • REJECTED.

The frontend must not determine which real state is behind not_signed.

Signed signers continue to receive:

displayStatus: "signed"

Status constants

Add the missing real workflow constants:

FILE_STATUS.CANCELED = 6
SIGN_REQUEST_STATUS.REJECTED = 3

These values must stay synchronized with:

  • lib/Enum/FileStatus.php;
  • lib/Enum/SignRequestStatus.php.

Do not add not_signed to SIGN_REQUEST_STATUS.

It belongs only to the response presentation type.

TypeScript contract

Update:

src/services/validationDocument.ts

and related/generated types to support:

type SignerDisplayStatus =
  | 'draft'
  | 'ready_to_sign'
  | 'signed'
  | 'rejected'
  | 'not_signed'

The validation parser must accept the displayStatus field and continue validating all other values.

Do not loosen validation to arbitrary strings.

Support:

  • FileStatus::CANCELED;
  • SignRequestStatus::REJECTED when exposed;
  • optional rejection;
  • the defined displayStatus values.

File status presentation

Update the shared file status helpers so:

FILE_STATUS.CANCELED

renders as:

Canceled

and never as Unknown.

Reuse the existing shared status infrastructure.

Normal file validation

Use the existing components:

src/components/validation/DocumentValidationDetails.vue
src/components/validation/SignerDetails.vue

Do not create a second signer component only for rejection.

Signed

When:

displayStatus === "signed"

keep the existing signed presentation.

Pending

When:

displayStatus === "ready_to_sign"

keep the current pending presentation.

Draft

When:

displayStatus === "draft"

keep the appropriate existing unsigned presentation.

Rejected

When:

displayStatus === "rejected"

show:

Rejected

instead of Not signed yet.

When available:

  • show rejection.rejectedAt;
  • show rejection.comment;
  • indicate a visible private comment when commentPrivate === true.

Do not show certificate information for rejected signers.

Not signed

When:

displayStatus === "not_signed"

show:

Not signed

using the frontend translation system.

This state is intentionally neutral.

Do not infer whether the signer:

  • is pending;
  • is draft;
  • rejected;
  • can sign.

Do not inspect another field to reconstruct the hidden state.

Canceled workflow

When:

document.status === FILE_STATUS.CANCELED

show the workflow as:

Canceled

Every signer that has not signed must be presented consistently as:

No longer able to sign

This applies regardless of whether their displayStatus is:

draft
ready_to_sign
not_signed
rejected

if rejection itself is not visible.

Signed signers keep their signed presentation.

Do not render not_signed differently from another unsigned signer in a canceled workflow, because that could identify the rejecting signer.

Workflow continues after rejection

When the document is not canceled and rejection is visible:

  • rejected signer shows Rejected;
  • other eligible signers keep their normal presentation;
  • workflow remains active.

When rejection is hidden:

  • unsigned signers may all have displayStatus: "not_signed";
  • render all of them as Not signed;
  • do not try to recover ready_to_sign;
  • do not identify the rejecting signer.

This loss of detail is intentional and required for privacy.

Envelope validation

Apply the same presentation rules to envelopes.

EnvelopeValidation.vue has its own signer summary, so updating only SignerDetails.vue is not sufficient.

For envelope validation:

  • support displayStatus;
  • support visible rejection;
  • support not_signed;
  • support canceled child files;
  • keep signed signer progress working;
  • do not identify a hidden rejecting signer through differences between unsigned entries.

Normal files and envelopes must consume the same backend contract.

Privacy boundary

The frontend must not evaluate backend rejection policy.

Do not:

  • evaluate public_status;
  • evaluate show_comment_on_validation;
  • decide whether comments should be visible;
  • use commentPrivate to decide authorization;
  • derive real SignRequestStatus from displayStatus;
  • infer rejection from not_signed;
  • infer rejection from FILE_STATUS.CANCELED;
  • infer which signer caused cancellation.

If the backend response leaks private information, fix #8388 instead of hiding it only in Vue.

Existing list/sidebar UI

file/list and sidebar components may also receive displayStatus after #8388.

Changing those interfaces is not part of this issue unless the backend contract introduces a regression.

#8162 remains focused on validation-page behavior.

Main implementation areas

Review at least:

  • src/constants.js;
  • src/utils/fileStatus.js;
  • src/services/validationDocument.ts;
  • generated/related TypeScript types;
  • src/components/validation/SignerDetails.vue;
  • src/components/validation/DocumentValidationDetails.vue;
  • src/components/validation/EnvelopeValidation.vue;
  • related frontend tests.

Tests

This behavior must be covered by frontend automated tests.

Parser

Test that:

  • every valid displayStatus is accepted;
  • arbitrary displayStatus strings are rejected;
  • SIGN_REQUEST_STATUS.REJECTED is accepted;
  • FILE_STATUS.CANCELED is accepted;
  • visible rejection without comment is accepted;
  • public comment is accepted;
  • private visible comment is accepted;
  • malformed rejection data is rejected.

Display status

Test each presentation mapping:

draft         -> existing draft/unsigned presentation
ready_to_sign -> existing pending presentation
signed        -> existing signed presentation
rejected      -> Rejected
not_signed    -> Not signed

Rejected signer

Test:

  • Rejected label;
  • rejection date;
  • visible rejection comment;
  • visible private marker;
  • no signed certificate presentation.

Hidden rejection

With multiple unsigned signers using:

displayStatus: "not_signed"

test that:

  • each receives the same presentation;
  • no rejection UI is shown;
  • no signer is presented as Ready to sign;
  • the frontend does not attempt to identify which signer rejected.

Canceled workflow

Test that:

  • workflow shows Canceled;
  • every non-signed signer shows No longer able to sign;
  • draft, ready_to_sign, and not_signed get the same canceled presentation;
  • signed signers remain signed;
  • no UI difference reveals the rejecting signer.

Continued workflow

Visible rejection:

  • rejected signer shows Rejected;
  • eligible signers keep normal state;
  • workflow remains active.

Hidden rejection:

  • all redacted unsigned signers show Not signed;
  • no signer is identified as the rejecter;
  • frontend does not reconstruct ready_to_sign.

Envelope

Cover:

  • normal signed/pending progress;
  • visible rejection;
  • hidden rejection;
  • multiple not_signed signers;
  • canceled workflow;
  • continued workflow;
  • visible rejection comment.

Use fixtures generated from the final backend contract implemented by #8388.

Do not reproduce backend policy logic in frontend tests.

Regression

Existing validation must continue to work for:

  • unsigned documents without rejection;
  • partially signed documents;
  • fully signed documents;
  • normal files;
  • envelopes;
  • anonymous validation;
  • authenticated validation.

Quality gates

The implementation must pass:

  • ESLint;
  • type checking;
  • frontend tests.

Out of scope

This issue does not implement:

  • rejection persistence;
  • rejection authorization;
  • rejection visibility decisions;
  • Policy Workbench configuration;
  • requester rejection configuration;
  • signer Reject action/dialog;
  • general sidebar or file-list redesign.

These are handled by #8159, #8388, #8160, and #8161.

Activity

  1. maia-andre commented on Sep 13, 2026

    @maia-andre
    Contributor

    I would like to take this one. Before touching the page I checked the precondition this issue states for the backend ("rejection cannot be discovered through status/statusText when public_status does not allow it"), on main after #8257, with two signers (joao, maria), the requester admin, and the system policy {"enabled":true,"comment_mode":"optional","cancel_workflow":false,"public_status":false,"show_comment_on_validation":false}. joao rejects with a public comment. Then:

    Viewer GET /file/validate/uuid/{uuid} — joao entry GET /file/list?details=1 — joao entry
    admin (requester) status: 3, statusText: "Rejected", no rejection status: 3, rejection: {rejectedAt, comment, commentPrivate: false}
    joao (who rejected, me: true) status: 3, no rejection —
    maria (other signer) status: 3, "Rejected", no rejection status: 3, "Rejected", no rejection
    anonymous status: 3, "Rejected", no rejection —

    With cancel_workflow: true the anonymous validation payload is file.status: 6 "Canceled", joao: 3 "Rejected", maria: 1 "Ready to sign".

    So the precondition is not met, and there are two distinct gaps in the backend contract:

    1. The validation payload never carries rejection. SignatureRejectionVisibilityService::buildSignerRejection() is only wired into FileListService (list/sidebar). The validation page is built by FileService → File\SignersLoader::loadLibreSignSigners(), which copies status/statusText straight from the entity and knows nothing about rejection. Even the requester gets nothing on the page this issue is about.
    2. status/statusText disclose the rejection to everyone, on both payloads. The visibility service hides the rejection object, but 3 / Rejected stays; the frontend cannot fix that without inferring, which this issue forbids.

    Neither is covered by tests today: tests/integration/features/sign/reject.feature only asserts on file/list, and every scenario that reads as another signer uses public_status: true.

    Proposal

    Two PRs under this issue, backend first, so the page is built on the contract the issue describes:

    PR 1 — backend contract (small)

    • SignersLoader builds rejection through the existing SignatureRejectionVisibilityService, privileged when the entry is me or the viewer is the file's requester — the same rule FileListService applies.
    • When a signer is REJECTED and the viewer is not allowed to see it, the signer is presented with a masked status/statusText — in SignersLoader and in both FileListService formatters, through one method on the visibility service so the rule lives in one place.
    • Behat: the validation endpoint with public_status: false (other signer and anonymous get no rejection and no rejected status; requester and the rejecting signer do), with public_status: true, with show_comment_on_validation, and with cancel_workflow. Unit tests for the loader and the new visibility method.

    PR 2 — this issue (frontend): SignerDetails/FileValidation render rejection (state, timestamp, comment, private marker for privileged viewers), the canceled workflow (file.status === 6: unsigned signers shown as no longer able to sign, no hint of who caused it), and the continued workflow; tests as listed in the issue.

    Two decisions I need from you

    1. What a hidden rejection looks like. The signer was able to sign, so masking to 1 / Ready to sign is the pre-rejection state and needs no new value in LibresignSignerSummary.status: 0|1|2|3. On a canceled workflow the page would then show every unsigned signer the same way ("no longer able to sign", FR7 of Configurable signature rejection workflow #7832), which is exactly the non-disclosure this issue asks for. The alternative is a new explicit state (e.g. closed), which is a contract change and a hint that something happened. I lean to masking to 1, and to leaving file.status: 6 "Canceled" public as it is today.
    2. Where to track the backend part: as PR 1 of this issue, or as a separate backend issue linked here (it is the precondition you wrote, so I would keep it here unless you prefer the split for the changelog).
  2. vitormattos commented on Sep 13, 2026

    @vitormattos
    MemberAuthor

    @maia-andre thanks for checking this in detail.

    I split the backend visibility fix into #8388 and updated #8162 to depend on that contract. I also adjusted the validation issue to handle the redacted signer state without duplicating privacy rules in the frontend.

    Could you review both issues and point out anything that still looks unclear or inconsistent before implementation starts?

  3. maia-andre commented on Sep 13, 2026

    @maia-andre
    Contributor

    Thanks — read both against the code. The split and the rule "one shared visibility decision, consumed by every serializer" are clear, and the response paths listed in #8388 match what I found (SignersLoader, FileListService ×2, FileService::mapSignerDetailsToSummary() — which today maps 3 to DRAFT through normalizeSignerSummaryStatus() — and EnvelopeAssembler, which already receives FileResponseOptions, so the privileged decision can come from getMe() / getSignRequest() / isSignerIdentified() there too). Two things look inconsistent to me, plus a few small ones.

    1. The redacted representation still identifies the signer who rejected

    Today rejection is the only reason a signer status is ever redacted. So for any reader who knows the contract (which #8162 documents), status: null means "this signer rejected and I am not allowed to see it" — the redaction hides the timestamp and the comment, but not the fact. Two concrete cases:

    • Workflow continues (cancel_workflow: false, public_status: false): maria is 1 "Ready to sign", joao is null "Not signed". joao is singled out by elimination.
    • Workflow canceled: Show signature rejection information on the validation page #8162 says the remaining unsigned signers are shown as No longer able to sign (from FILE_STATUS.CANCELED) while the redacted one stays Not signed. The different label alone identifies who rejected, which is exactly what both issues forbid ("must not be able to identify which signer rejected", "do not infer the rejecting signer from the canceled state").

    I see three ways to make the redaction actually non-disclosing; it is your call which one:

    • (a) Redact every unsigned signer of that file when the viewer cannot see a rejection in it — all of them become null / "Not signed", so null no longer points at anyone. Signed signers are untouched. In a canceled workflow this costs nothing (they are all "no longer able to sign" anyway); in a continued workflow the public page loses "Ready to sign" for the pending signers of that one file, which seems an acceptable price for the privacy the policy promises.
    • (b) Keep null only on the rejected signer and state in both issues that redaction hides when and why, not who. Cheaper, but then public_status: false does less than its name says.
    • (c) Represent the hidden rejection exactly like a pending signer (1). You already ruled this out because it misstates the workflow, so I only list it for completeness.

    I would go with (a). Whatever the choice, #8162's canceled-workflow section should render every unsigned signer the same way (the same label for status: 1 and status: null when document.status === CANCELED), otherwise the frontend reintroduces the leak.

    2. Small points on #8388

    • LibresignSignerSummary.status: 0|1|2|3 is shared by the three contracts you list, and all three paths can return a redacted entry, so the type becomes 0|1|2|3|null in all of them (and in the generated TypeScript). Fine, just noting it is not "only where needed" in practice.
    • "Not signed" will be a new translated backend string produced by the visibility service (the current labels come from SignRequestStatus::getLabel()), so the service needs IL10N. Trivial, but it means the label lives in the backend; see 3.
    • file/list is also consumed by an authenticated signer who is not the requester (maria sees this file in her list). Her joao entry will be redacted there too; the list/sidebar components (Signer.vue chip uses statusText; getSignRequestStatusText() switches on status) are outside Show signature rejection information on the validation page #8162's scope. I checked and they degrade to the backend statusText, so nothing breaks, but it may be worth one line in Apply signature rejection visibility rules to signer status and validation API #8388's tests ("detailed file response as another signer") — which is already there — and one line in Show signature rejection information on the validation page #8162 saying the list is intentionally not touched.
    • Behat: the validation endpoint as anonymous and as an identified signer UUID are the two flows that do not exist in reject.feature today; the rest (requester, other signer) can extend the existing scenarios.

    3. Small points on #8162

    • Confirmed on main: SIGN_REQUEST_STATUS has no REJECTED, FILE_STATUS has no CANCELED, getStatusLabel(6) returns Unknown, and isSignerStatus() in validationDocument.ts accepts only 0|1|2 — so the items are right.
    • "Render the signer as Not signed": please say whether the page prints the backend statusText or its own t('libresign', 'Not signed') keyed on status === null. SignerDetails.vue prints its own Not signed yet for pending signers today, so the two labels will sit next to each other; I would key it on status === null in the frontend and treat statusText as informative only, so the wording of the two states is decided in one place.
    • "Present remaining unsigned signers as No longer able to sign when their signer state is otherwise visible": with option (a) above this is simply "every signer that is not signed". Without it, please define whether DRAFT (0) counts as "remaining unsigned".

    Nothing else looks inconsistent to me. If it helps sequencing, I can take #8388 first (it is the contract #8162 needs, and I already have the reproduction set up), then #8162 on top of it.

  4. vitormattos commented on Sep 13, 2026

    @vitormattos
    MemberAuthor

    Good catch. I agree with option (a).

    If a rejection is hidden, redacting only the rejected signer still identifies them by comparison. We should redact all unsigned signers of that file for that viewer, while keeping signed signers unchanged.

    For a canceled workflow, the validation page should then render every non-signed signer the same way as no longer able to sign.

    I will update #8388 and #8162 with this contract. You can take #8388 first, then #8162 on top of it.

  5. vitormattos commented on Sep 13, 2026

    @vitormattos
    MemberAuthor

    I updated #8388 and #8162 accordingly. The contract now uses a separate displayStatus, and a hidden rejection makes all unsigned signers use the same not_signed presentation without exposing their real status.

    You can proceed with #8388 first, then #8162.

  6. maia-andre commented on Oct 2, 2026

    @maia-andre
    Contributor

    Starting on this now that #8388 is closed by #8398, as agreed above.

    Part of the groundwork already landed with #8398: FILE_STATUS.CANCELED, SIGN_REQUEST_STATUS.REJECTED and the displayStatus type are in place, and the validation parser accepts REJECTED. What is still missing for this issue:

    • src/utils/fileStatus.js has no CANCELED entry, so a canceled document still renders as Unknown;
    • SignerDetails.vue only knows signed / "Not signed yet" and does not read displayStatus or rejection;
    • EnvelopeValidation.vue builds its own signer summary from signed / status, so it needs the same mapping;
    • parser tests for the displayStatus values and for malformed rejection data.

    I'll keep to the contract in the issue: presentation keyed only on displayStatus and document.status, every non-signed signer rendered the same way in a canceled workflow, and no field inspected to reconstruct a hidden state. Tests follow the list in the issue, with fixtures taken from real responses of the endpoint after #8398.

    One wording question: in a continued workflow, should a pending signer (ready_to_sign) keep the current "Not signed yet" label? A hidden one (not_signed) would show "Not signed". The two labels are close enough that I'd like your confirmation they are meant to differ.

  7. added a commit that references this issue on Oct 6, 2026
    26893e3
  8. moved this from 0. Backlog to 4. Done in LibreSign Roadmapon Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    frontendFrontend workjavascriptWork involving JavaScript code

    Fields

    Priority

    Low

    Start date

    None yet

    Target date

    None yet

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions