Merge pull request #6231 from LibreSign/feat/signing-order-visual-diagram

feat: signing order visual diagram
This commit is contained in:
Vitor Mattos 2025-12-17 02:53:00 -03:00 committed by GitHub
commit 45806e18d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 738 additions and 46 deletions

View file

@ -95,7 +95,7 @@ class PageController extends AEnvironmentPageAwareController {
$this->provideSignerSignatues();
$this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings());
$this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::PARALLEL->value));
$this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value));
$this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information'));
Util::addScript(Application::APP_ID, 'libresign-main');

View file

@ -136,6 +136,7 @@ class RequestSignatureController extends AEnvironmentAwareController {
* @param LibresignVisibleElement[]|null $visibleElements Visible elements on document
* @param LibresignNewFile|array<empty>|null $file File object.
* @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
* @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration
* @return DataResponse<Http::STATUS_OK, array{message: string, data: LibresignValidateFile}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{message?: string, action?: integer, errors?: list<array{message: string, title?: string}>}, array{}>
*
* 200: OK
@ -145,7 +146,14 @@ class RequestSignatureController extends AEnvironmentAwareController {
#[NoCSRFRequired]
#[RequireManager]
#[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/request-signature', requirements: ['apiVersion' => '(v1)'])]
public function updateSign(?array $users = [], ?string $uuid = null, ?array $visibleElements = null, ?array $file = [], ?int $status = null): DataResponse {
public function updateSign(
?array $users = [],
?string $uuid = null,
?array $visibleElements = null,
?array $file = [],
?int $status = null,
?string $signatureFlow = null,
): DataResponse {
$user = $this->userSession->getUser();
$data = [
'uuid' => $uuid,
@ -153,7 +161,8 @@ class RequestSignatureController extends AEnvironmentAwareController {
'users' => $users,
'userManager' => $user,
'status' => $status,
'visibleElements' => $visibleElements
'visibleElements' => $visibleElements,
'signatureFlow' => $signatureFlow,
];
try {
$this->validateHelper->validateExistingFile($data);

View file

@ -57,7 +57,7 @@ class File extends Entity {
protected ?string $callback = null;
protected ?array $metadata = null;
protected int $modificationStatus = 0;
protected int $signatureFlow = SignatureFlow::NUMERIC_PARALLEL;
protected int $signatureFlow = SignatureFlow::NUMERIC_NONE;
protected int $docmdpLevel = 0;
public const STATUS_NOT_LIBRESIGN_FILE = -1;
public const STATUS_DRAFT = 0;

View file

@ -13,14 +13,17 @@ namespace OCA\Libresign\Enum;
* Signature flow modes
*/
enum SignatureFlow: string {
case NONE = 'none';
case PARALLEL = 'parallel';
case ORDERED_NUMERIC = 'ordered_numeric';
public const NUMERIC_NONE = 0;
public const NUMERIC_PARALLEL = 1;
public const NUMERIC_ORDERED_NUMERIC = 2;
public function toNumeric(): int {
return match($this) {
self::NONE => self::NUMERIC_NONE,
self::PARALLEL => self::NUMERIC_PARALLEL,
self::ORDERED_NUMERIC => self::NUMERIC_ORDERED_NUMERIC,
};
@ -28,6 +31,7 @@ enum SignatureFlow: string {
public static function fromNumeric(int $value): self {
return match($value) {
self::NUMERIC_NONE => self::NONE,
self::NUMERIC_PARALLEL => self::PARALLEL,
self::NUMERIC_ORDERED_NUMERIC => self::ORDERED_NUMERIC,
default => throw new \ValueError("Invalid numeric value for SignatureFlow: $value"),

View file

@ -60,7 +60,7 @@ class TemplateLoader implements IEventListener {
$this->initialState->provideInitialState(
'signature_flow',
$this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::PARALLEL->value)
$this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value)
);
try {

View file

@ -363,8 +363,8 @@ abstract class AEngineHandler implements IEngineHandler {
}
$pkiDirName = $this->caIdentifierService->generatePkiDirectoryName($caId);
$dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
$instanceId = $this->config->getSystemValue('instanceid');
$pkiPath = $dataDir . '/appdata_' . $instanceId . '/libresign/' . $pkiDirName;
$systemInstanceId = $this->config->getSystemValue('instanceid');
$pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
if (!is_dir($pkiPath)) {
$this->createDirectoryWithCorrectOwnership($pkiPath);

View file

@ -62,8 +62,8 @@ class Version15000Date20251209000000 extends SimpleMigrationStep {
if (!$tableFile->hasColumn('signature_flow')) {
$tableFile->addColumn('signature_flow', Types::SMALLINT, [
'notnull' => true,
'default' => SignatureFlow::NUMERIC_PARALLEL,
'comment' => 'Signature flow mode: 1=parallel, 2=ordered_numeric',
'default' => SignatureFlow::NUMERIC_NONE,
'comment' => 'Signature flow mode: 0=none (no admin enforcement), 1=parallel, 2=ordered_numeric',
]);
}
}

View file

@ -79,6 +79,7 @@ class RequestSignatureService {
public function saveFile(array $data): FileEntity {
if (!empty($data['uuid'])) {
$file = $this->fileMapper->getByUuid($data['uuid']);
$this->updateSignatureFlowIfAllowed($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
}
$fileId = null;
@ -90,6 +91,7 @@ class RequestSignatureService {
if (!is_null($fileId)) {
try {
$file = $this->fileMapper->getByFileId($fileId);
$this->updateSignatureFlowIfAllowed($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
} catch (\Throwable) {
}
@ -118,27 +120,45 @@ class RequestSignatureService {
$file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN);
}
if (isset($data['signatureFlow']) && is_string($data['signatureFlow'])) {
try {
$signatureFlow = \OCA\Libresign\Enum\SignatureFlow::from($data['signatureFlow']);
$file->setSignatureFlowEnum($signatureFlow);
} catch (\ValueError) {
$this->setSignatureFlowFromGlobalConfig($file);
}
} else {
$this->setSignatureFlowFromGlobalConfig($file);
}
$this->setSignatureFlow($file, $data);
$this->setDocMdpLevelFromGlobalConfig($file);
$this->fileMapper->insert($file);
return $file;
}
private function setSignatureFlowFromGlobalConfig(FileEntity $file): void {
$globalFlowValue = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::PARALLEL->value);
$globalFlow = SignatureFlow::from($globalFlowValue);
$file->setSignatureFlowEnum($globalFlow);
private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void {
$adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
$adminForcedConfig = $adminFlow !== SignatureFlow::NONE->value;
if ($adminForcedConfig) {
$adminFlowEnum = SignatureFlow::from($adminFlow);
if ($file->getSignatureFlowEnum() !== $adminFlowEnum) {
$file->setSignatureFlowEnum($adminFlowEnum);
$this->fileMapper->update($file);
}
return;
}
if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) {
$newFlow = SignatureFlow::from($data['signatureFlow']);
if ($file->getSignatureFlowEnum() !== $newFlow) {
$file->setSignatureFlowEnum($newFlow);
$this->fileMapper->update($file);
}
}
}
private function setSignatureFlow(FileEntity $file, array $data): void {
$adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) {
$file->setSignatureFlowEnum(SignatureFlow::from($data['signatureFlow']));
} elseif ($adminFlow !== SignatureFlow::NONE->value) {
$file->setSignatureFlowEnum(SignatureFlow::from($adminFlow));
} else {
$file->setSignatureFlowEnum(SignatureFlow::NONE);
}
}
private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void {

View file

@ -79,7 +79,7 @@ class Admin implements ISettings {
$this->initialState->provideInitialState('tsa_username', $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', ''));
$this->initialState->provideInitialState('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER));
$this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig());
$this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', ''));
$this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value));
return new TemplateResponse(Application::APP_ID, 'admin_settings');
}

View file

@ -6679,6 +6679,11 @@
"format": "int64",
"nullable": true,
"description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending"
},
"signatureFlow": {
"type": "string",
"nullable": true,
"description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration"
}
}
}

View file

@ -6529,6 +6529,11 @@
"format": "int64",
"nullable": true,
"description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending"
},
"signatureFlow": {
"type": "string",
"nullable": true,
"description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration"
}
}
}

View file

@ -15,6 +15,20 @@
@click="addSigner">
{{ t('libresign', 'Add signer') }}
</NcButton>
<NcCheckboxRadioSwitch v-if="showPreserveOrder"
v-model="preserveOrder"
type="switch"
@update:checked="onPreserveOrderChange">
{{ t('libresign', 'Preserve signing order') }}
</NcCheckboxRadioSwitch>
<NcButton v-if="showViewOrderButton"
type="tertiary"
@click="showOrderDiagram = true">
<template #icon>
<ChartGantt :size="20" />
</template>
{{ t('libresign', 'View signing order') }}
</NcButton>
<Signers event="libresign:edit-signer"
@signing-order-changed="debouncedSave">
<template #actions="{signer, closeActions}">
@ -192,6 +206,18 @@
</NcButton>
</template>
</NcDialog>
<NcDialog v-if="showOrderDiagram"
:name="t('libresign', 'Signing order diagram')"
size="large"
@closing="showOrderDiagram = false">
<SigningOrderDiagram :signers="filesStore.getFile()?.signers || []"
:sender-name="currentUserDisplayName" />
<template #actions>
<NcButton @click="showOrderDiagram = false">
{{ t('libresign', 'Close') }}
</NcButton>
</template>
</NcDialog>
</div>
</template>
<script>
@ -200,11 +226,13 @@ import debounce from 'debounce'
import svgAccount from '@mdi/svg/svg/account.svg?raw'
import svgEmail from '@mdi/svg/svg/email.svg?raw'
import svgInfo from '@mdi/svg/svg/information-outline.svg?raw'
import svgSms from '@mdi/svg/svg/message-processing.svg?raw'
import svgWhatsapp from '@mdi/svg/svg/whatsapp.svg?raw'
import svgXmpp from '@mdi/svg/svg/xmpp.svg?raw'
import Bell from 'vue-material-design-icons/Bell.vue'
import ChartGantt from 'vue-material-design-icons/ChartGantt.vue'
import Delete from 'vue-material-design-icons/Delete.vue'
import Draw from 'vue-material-design-icons/Draw.vue'
import FileDocument from 'vue-material-design-icons/FileDocument.vue'
@ -227,6 +255,7 @@ import NcActions from '@nextcloud/vue/components/NcActions'
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
@ -234,8 +263,9 @@ import NcModal from '@nextcloud/vue/components/NcModal'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IdentifySigner from '../Request/IdentifySigner.vue'
import VisibleElements from '../Request/VisibleElements.vue'
import Signers from '../Signers/Signers.vue'
import SigningOrderDiagram from '../SigningOrder/SigningOrderDiagram.vue'
import VisibleElements from '../Request/VisibleElements.vue'
import svgSignal from '../../../img/logo-signal-app.svg?raw'
import svgTelegram from '../../../img/logo-telegram-app.svg?raw'
@ -262,28 +292,31 @@ export default {
name: 'RequestSignatureTab',
mixins: [signingOrderMixin],
components: {
Bell,
ChartGantt,
Delete,
Draw,
FileDocument,
IdentifySigner,
Information,
MessageText,
NcActionButton,
NcActionInput,
NcActions,
NcAppSidebar,
NcAppSidebarTab,
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
NcIconSvgWrapper,
NcLoadingIcon,
NcModal,
NcNoteCard,
NcDialog,
Bell,
Delete,
Draw,
FileDocument,
Information,
MessageText,
OrderNumericAscending,
Pencil,
Send,
Signers,
IdentifySigner,
SigningOrderDiagram,
VisibleElements,
},
props: {
@ -311,23 +344,61 @@ export default {
showConfirmRequestSigner: false,
selectedSigner: null,
activeTab: '',
preserveOrder: false,
showOrderDiagram: false,
infoIcon: svgInfo,
adminSignatureFlow: '',
lastSyncedFileId: null,
}
},
computed: {
signatureFlow() {
const file = this.filesStore.getFile()
return file?.signatureFlow ?? 'parallel'
let flow = file?.signatureFlow
if (typeof flow === 'number') {
const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
flow = flowMap[flow]
}
if (flow && flow !== 'none') {
return flow
}
if (this.adminSignatureFlow && this.adminSignatureFlow !== 'none') {
return this.adminSignatureFlow
}
return 'parallel'
},
isAdminFlowForced() {
return this.adminSignatureFlow && this.adminSignatureFlow !== 'none'
},
isOrderedNumeric() {
return this.signatureFlow === 'ordered_numeric'
},
showSigningOrderOptions() {
return this.hasSigners && this.filesStore.canSave() && !this.isAdminFlowForced
},
showPreserveOrder() {
return this.totalSigners > 1 && this.filesStore.canSave() && !this.isAdminFlowForced
},
showViewOrderButton() {
return this.isOrderedNumeric && this.totalSigners > 1 && this.hasSigners
},
shouldShowOrderedOptions() {
return this.isOrderedNumeric && this.totalSigners > 1
},
currentUserDisplayName() {
return OC.getCurrentUser()?.displayName || ''
},
showDocMdpWarning() {
return this.filesStore.isDocMdpNoChangesAllowed() && !this.filesStore.canAddSigner()
},
canEditSigningOrder() {
return (signer) => {
const minSigners = this.isAdminFlowForced ? 1 : 2
return this.isOrderedNumeric
&& this.totalSigners > 1
&& this.totalSigners >= minSigners
&& this.filesStore.canSave()
&& !signer.signed
}
@ -490,12 +561,25 @@ export default {
signers(signers) {
this.init(signers)
},
'filesStore.selectedNodeId': {
handler(newFileId, oldFileId) {
if (newFileId && newFileId !== this.lastSyncedFileId) {
this.syncPreserveOrderWithFile()
this.lastSyncedFileId = newFileId
}
},
immediate: true,
},
},
async mounted() {
subscribe('libresign:edit-signer', this.editSigner)
this.filesStore.disableIdentifySigner()
this.activeTab = this.userConfigStore.signer_identify_tab || ''
this.adminSignatureFlow = loadState('libresign', 'signature_flow', 'none')
this.syncPreserveOrderWithFile()
},
beforeUnmount() {
unsubscribe('libresign:edit-signer')
@ -506,7 +590,11 @@ export default {
this.debouncedSave = debounce(async () => {
try {
await this.filesStore.saveWithVisibleElements({ visibleElements: [] })
const file = this.filesStore.getFile()
await this.filesStore.saveWithVisibleElements({
visibleElements: [],
signatureFlow: file?.signatureFlow,
})
} catch (error) {
if (error.response?.data?.ocs?.data?.message) {
showError(error.response.data.ocs.data.message)
@ -521,6 +609,56 @@ export default {
}, 500)
},
methods: {
onPreserveOrderChange(value) {
this.preserveOrder = value
const file = this.filesStore.getFile()
if (value) {
if (file?.signers) {
file.signers.forEach((signer, index) => {
if (!signer.signingOrder) {
this.$set(signer, 'signingOrder', index + 1)
}
})
}
if (file) {
this.$set(file, 'signatureFlow', 'ordered_numeric')
}
} else {
if (!this.isAdminFlowForced) {
if (file?.signers) {
file.signers.forEach(signer => {
if (!signer.signed) {
this.$set(signer, 'signingOrder', 1)
}
})
}
if (file) {
this.$set(file, 'signatureFlow', 'parallel')
}
}
}
this.debouncedSave()
},
syncPreserveOrderWithFile() {
const file = this.filesStore.getFile()
if (!file) {
this.preserveOrder = false
return
}
const flow = file.signatureFlow
this.lastSyncedFileId = this.filesStore.selectedNodeId
if ((flow === 'ordered_numeric' || flow === 2) && !this.isAdminFlowForced) {
this.preserveOrder = true
} else {
this.preserveOrder = false
}
},
getSvgIcon(name) {
return iconMap[`svg${name.charAt(0).toUpperCase() + name.slice(1)}`] || iconMap.svgAccount
},
@ -789,6 +927,10 @@ export default {
</script>
<style lang="scss" scoped>
:deep(.checkbox-radio-switch) {
margin: 8px 0;
}
.action-buttons {
display: flex;
flex-direction: column;

View file

@ -96,7 +96,14 @@ export default {
computed: {
signatureFlow() {
const file = this.filesStore.getFile()
return file?.signatureFlow ?? 'parallel'
let flow = file?.signatureFlow ?? 'parallel'
if (typeof flow === 'number') {
const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
flow = flowMap[flow] || 'parallel'
}
return flow
},
signer() {
const file = this.filesStore.getFile()

View file

@ -83,7 +83,14 @@ export default {
},
isOrderedNumeric() {
const file = this.filesStore.getFile()
return file?.signatureFlow === 'ordered_numeric'
let flow = file?.signatureFlow
if (typeof flow === 'number') {
const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
flow = flowMap[flow]
}
return flow === 'ordered_numeric'
},
canReorder() {
return this.filesStore.canSave() && this.signers.length > 1

View file

@ -0,0 +1,473 @@
<!--
- SPDX-FileCopyrightText: 2025 LibreCode coop and LibreCode contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="signing-order-diagram">
<div class="diagram-content">
<div class="stage">
<div class="stage-label">{{ t('libresign', 'SENDER') }}</div>
<div class="stage-items">
<div class="signer-node sender">
<div class="signer-content">
<NcAvatar :user="senderName" :size="40" />
<div class="signer-info">
<div class="signer-name">{{ senderName }}</div>
</div>
</div>
</div>
</div>
</div>
<div v-for="order in uniqueOrders" :key="order" class="stage">
<div class="stage-number">{{ order }}</div>
<div class="stage-items">
<div v-for="(signer, index) in getSignersByOrder(order)"
:key="`${order}-${index}`"
class="signer-node"
:class="{ signed: signer.signed }">
<NcPopover>
<template #trigger="{ attrs }">
<button class="signer-content"
v-bind="attrs"
type="button">
<div class="avatar-container">
<NcAvatar :display-name="getSignerDisplayName(signer)" :size="40" />
<div class="status-indicator" :class="getStatusClass(signer)" />
</div>
<div class="signer-info">
<div class="signer-name">{{ getSignerDisplayName(signer) }}</div>
<div class="signer-identify">{{ getSignerIdentify(signer) }}</div>
</div>
</button>
</template>
<div class="popover-content" tabindex="0">
<div class="popover-row">
<strong>{{ t('libresign', 'Name') }}:</strong>
<span>{{ getSignerDisplayName(signer) }}</span>
</div>
<div class="popover-row">
<strong>{{ t('libresign', 'Method') }}:</strong>
<div class="method-chips">
<NcChip v-for="method in getIdentifyMethods(signer)"
:key="method"
:text="method"
:no-close="true" />
</div>
</div>
<div class="popover-row">
<strong>{{ t('libresign', 'Contact') }}:</strong>
<span>{{ getSignerIdentify(signer) }}</span>
</div>
<div class="popover-row">
<strong>{{ t('libresign', 'Status') }}:</strong>
<NcChip :text="getStatusLabel(signer)"
:type="getChipType(signer)"
:icon-path="getStatusIconPath(signer)"
:no-close="true" />
</div>
<div class="popover-row">
<strong>{{ t('libresign', 'Order') }}:</strong>
<span>{{ signer.signingOrder || 1 }}</span>
</div>
<div v-if="signer.signed && signer.signDate" class="popover-row">
<strong>{{ t('libresign', 'Signed at') }}:</strong>
<span>{{ formatDate(signer.signDate) }}</span>
</div>
</div>
</NcPopover>
</div>
</div>
</div>
<div class="stage">
<div class="stage-label">{{ t('libresign', 'COMPLETED') }}</div>
<div class="stage-items">
<div class="signer-node completed">
<div class="signer-icon">
<Check :size="24" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mdiCheckCircle, mdiClockOutline, mdiCircleOutline } from '@mdi/js'
import Check from 'vue-material-design-icons/Check.vue'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
import NcPopover from '@nextcloud/vue/dist/Components/NcPopover.js'
export default {
name: 'SigningOrderDiagram',
components: {
NcAvatar,
NcChip,
NcPopover,
Check,
},
setup() {
return {
mdiCheckCircle,
mdiClockOutline,
mdiCircleOutline,
}
},
props: {
signers: {
type: Array,
required: true,
},
senderName: {
type: String,
default: '',
},
},
computed: {
uniqueOrders() {
const orders = this.signers.map(s => s.signingOrder || 1)
return [...new Set(orders)].sort((a, b) => a - b)
},
},
methods: {
getSignersByOrder(order) {
return this.signers.filter(s => (s.signingOrder || 1) === order)
},
getSignerDisplayName(signer) {
return signer.displayName || signer.identifyMethods?.[0]?.value || this.t('libresign', 'Signer')
},
getSignerIdentify(signer) {
const method = signer.identifyMethods?.[0]?.method
const value = signer.identifyMethods?.[0]?.value
if (method === 'email') {
return value
}
if (method === 'account') {
return `v.${method}.${value}@colab.rio`
}
return value
},
getIdentifyMethods(signer) {
return signer.identifyMethods?.map(method => method.method) || []
},
getStatusLabel(signer) {
if (signer.signed) return this.t('libresign', 'Signed')
if (signer.me?.status === 0) return this.t('libresign', 'Draft')
return this.t('libresign', 'Pending')
},
getStatusIconPath(signer) {
if (signer.signed) return this.mdiCheckCircle
if (signer.me?.status === 0) return this.mdiCircleOutline
return this.mdiClockOutline
},
getChipType(signer) {
if (signer.signed) return 'success'
if (signer.me?.status === 0) return 'secondary'
return 'warning'
},
getStatusClass(signer) {
if (signer.signed) return 'signed'
if (signer.me?.status === 0) return 'draft'
return 'pending'
},
formatDate(timestamp) {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
return date.toLocaleString()
},
},
}
</script>
<style lang="scss" scoped>
.signing-order-diagram {
padding: 20px 16px;
max-height: 70vh;
overflow-y: auto;
@media (max-width: 512px) {
padding: 16px 12px;
}
}
.diagram-content {
display: flex;
flex-direction: column;
position: relative;
max-width: 700px;
margin: 0 auto;
padding-bottom: 16px;
@media (max-width: 512px) {
max-width: 100%;
padding-bottom: 12px;
}
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
padding: 16px 0;
border-bottom: 1px solid var(--color-border);
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:not(:last-child)::after {
content: '';
position: absolute;
bottom: -10px;
left: 50%;
width: 2px;
height: 10px;
background: var(--color-border-dark);
transform: translateX(-50%);
z-index: 1;
@media (max-width: 512px) {
bottom: -8px;
height: 8px;
}
}
.stage-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-maxcontrast);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
text-align: center;
@media (max-width: 512px) {
font-size: 11px;
margin-bottom: 10px;
}
}
.stage-number {
position: absolute;
left: 0;
top: 16px;
font-size: 24px;
font-weight: 600;
color: var(--color-text-maxcontrast);
width: 50px;
text-align: center;
@media (max-width: 512px) {
font-size: 20px;
width: 40px;
}
}
.stage-items {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
@media (max-width: 512px) {
gap: 10px;
}
}
}
.signer-node {
background: var(--color-main-background);
border: 2px solid var(--color-border);
border-radius: 50px;
min-width: 220px;
max-width: 100%;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
@media (max-width: 512px) {
min-width: 180px;
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.signer-content {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
width: 100%;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
@media (max-width: 512px) {
padding: 10px 16px;
gap: 10px;
}
}
&.sender {
background: var(--color-primary-element-light);
border-color: var(--color-primary-element);
.signer-name {
font-weight: 600;
}
}
&.completed {
background: var(--color-success);
border-color: var(--color-success);
justify-content: center;
min-width: 64px;
padding: 16px;
@media (max-width: 512px) {
min-width: 56px;
padding: 14px;
}
.signer-icon {
background: transparent;
color: white;
width: 32px;
height: 32px;
@media (max-width: 512px) {
width: 28px;
height: 28px;
}
}
}
.avatar-container {
position: relative;
flex-shrink: 0;
.status-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 12px;
height: 12px;
border: 2px solid var(--color-main-background);
border-radius: 50%;
@media (max-width: 512px) {
width: 10px;
height: 10px;
}
&.signed {
background: var(--color-success);
}
&.pending {
background: var(--color-warning);
}
&.draft {
background: var(--color-text-maxcontrast);
}
}
}
.signer-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
@media (max-width: 512px) {
width: 28px;
height: 28px;
}
}
.signer-info {
flex: 1;
min-width: 0;
.signer-name {
font-size: 14px;
font-weight: 500;
color: var(--color-main-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@media (max-width: 512px) {
font-size: 13px;
}
}
.signer-identify {
font-size: 12px;
color: var(--color-text-maxcontrast);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
@media (max-width: 512px) {
font-size: 11px;
}
}
}
}
.popover-content {
padding: 12px;
min-width: 250px;
outline: none;
.popover-row {
display: grid;
grid-template-columns: 80px 1fr;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
align-items: start;
&:last-child {
border-bottom: none;
}
strong {
font-size: 13px;
color: var(--color-text-maxcontrast);
padding-top: 2px;
}
span {
font-size: 14px;
color: var(--color-main-text);
word-break: break-word;
}
.method-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
}
</style>

View file

@ -381,8 +381,15 @@ export const useFilesStore = function(...args) {
filesSorted() {
return this.ordered.map(key => this.files[key])
},
async saveWithVisibleElements({ visibleElements = [], signers = null, uuid = null, nodeId = null }) {
async saveWithVisibleElements({ visibleElements = [], signers = null, uuid = null, nodeId = null, signatureFlow = null }) {
const file = this.getFile()
let flowValue = signatureFlow || file.signatureFlow
if (typeof flowValue === 'number') {
const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
flowValue = flowMap[flowValue] || 'parallel'
}
const config = {
url: generateOcsUrl('/apps/libresign/api/v1/request-signature'),
method: uuid || file.uuid ? 'patch' : 'post',
@ -391,10 +398,12 @@ export const useFilesStore = function(...args) {
users: signers || file.signers,
visibleElements,
status: 0,
signatureFlow: flowValue,
},
}
if (uuid || file.uuid) {
if (uuid || file.uuid) {
config.data.uuid = uuid || file.uuid
} else {
config.data.file = {
@ -406,8 +415,15 @@ export const useFilesStore = function(...args) {
this.addFile(data.ocs.data.data)
return data.ocs.data
},
async updateSignatureRequest({ visibleElements = [], signers = null, uuid = null, nodeId = null, status = 1 }) {
async updateSignatureRequest({ visibleElements = [], signers = null, uuid = null, nodeId = null, status = 1, signatureFlow = null }) {
const file = this.getFile()
let flowValue = signatureFlow || file.signatureFlow
if (typeof flowValue === 'number') {
const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
flowValue = flowMap[flowValue] || 'parallel'
}
const config = {
url: generateOcsUrl('/apps/libresign/api/v1/request-signature'),
method: uuid || file.uuid ? 'patch' : 'post',
@ -416,6 +432,7 @@ export const useFilesStore = function(...args) {
users: signers || file.signers,
visibleElements,
status,
signatureFlow: flowValue,
},
}
@ -426,7 +443,6 @@ export const useFilesStore = function(...args) {
fileId: nodeId || this.selectedNodeId,
}
}
const { data } = await axios(config)
this.addFile(data.ocs.data.data)
return data.ocs.data

View file

@ -4106,6 +4106,8 @@ export interface operations {
* @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
*/
status?: number | null;
/** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */
signatureFlow?: string | null;
};
};
};

View file

@ -3628,6 +3628,8 @@ export interface operations {
* @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
*/
status?: number | null;
/** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */
signatureFlow?: string | null;
};
};
};

View file

@ -104,9 +104,9 @@ export default {
methods: {
loadConfig() {
try {
const mode = loadState('libresign', 'signature_flow', null)
const mode = loadState('libresign', 'signature_flow', 'none')
if (mode === null || mode === '') {
if (mode === 'none') {
this.enabled = false
this.selectedFlow = this.availableFlows[0]
} else {

View file

@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
final class SignatureFlowTest extends TestCase {
public static function validFlowProvider(): array {
return [
'none' => [SignatureFlow::NONE, SignatureFlow::NUMERIC_NONE, 'none'],
'parallel' => [SignatureFlow::PARALLEL, SignatureFlow::NUMERIC_PARALLEL, 'parallel'],
'ordered_numeric' => [SignatureFlow::ORDERED_NUMERIC, SignatureFlow::NUMERIC_ORDERED_NUMERIC, 'ordered_numeric'],
];
@ -29,7 +30,6 @@ final class SignatureFlowTest extends TestCase {
public static function invalidNumericProvider(): array {
return [
'zero' => [0],
'negative' => [-1],
'three' => [3],
'large' => [999],