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->provideSignerSignatues();
$this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); $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')); $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information'));
Util::addScript(Application::APP_ID, 'libresign-main'); 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 LibresignVisibleElement[]|null $visibleElements Visible elements on document
* @param LibresignNewFile|array<empty>|null $file File object. * @param LibresignNewFile|array<empty>|null $file File object.
* @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending * @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{}> * @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 * 200: OK
@ -145,7 +146,14 @@ class RequestSignatureController extends AEnvironmentAwareController {
#[NoCSRFRequired] #[NoCSRFRequired]
#[RequireManager] #[RequireManager]
#[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/request-signature', requirements: ['apiVersion' => '(v1)'])] #[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(); $user = $this->userSession->getUser();
$data = [ $data = [
'uuid' => $uuid, 'uuid' => $uuid,
@ -153,7 +161,8 @@ class RequestSignatureController extends AEnvironmentAwareController {
'users' => $users, 'users' => $users,
'userManager' => $user, 'userManager' => $user,
'status' => $status, 'status' => $status,
'visibleElements' => $visibleElements 'visibleElements' => $visibleElements,
'signatureFlow' => $signatureFlow,
]; ];
try { try {
$this->validateHelper->validateExistingFile($data); $this->validateHelper->validateExistingFile($data);

View file

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

View file

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

View file

@ -60,7 +60,7 @@ class TemplateLoader implements IEventListener {
$this->initialState->provideInitialState( $this->initialState->provideInitialState(
'signature_flow', '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 { try {

View file

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

View file

@ -62,8 +62,8 @@ class Version15000Date20251209000000 extends SimpleMigrationStep {
if (!$tableFile->hasColumn('signature_flow')) { if (!$tableFile->hasColumn('signature_flow')) {
$tableFile->addColumn('signature_flow', Types::SMALLINT, [ $tableFile->addColumn('signature_flow', Types::SMALLINT, [
'notnull' => true, 'notnull' => true,
'default' => SignatureFlow::NUMERIC_PARALLEL, 'default' => SignatureFlow::NUMERIC_NONE,
'comment' => 'Signature flow mode: 1=parallel, 2=ordered_numeric', '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 { public function saveFile(array $data): FileEntity {
if (!empty($data['uuid'])) { if (!empty($data['uuid'])) {
$file = $this->fileMapper->getByUuid($data['uuid']); $file = $this->fileMapper->getByUuid($data['uuid']);
$this->updateSignatureFlowIfAllowed($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
} }
$fileId = null; $fileId = null;
@ -90,6 +91,7 @@ class RequestSignatureService {
if (!is_null($fileId)) { if (!is_null($fileId)) {
try { try {
$file = $this->fileMapper->getByFileId($fileId); $file = $this->fileMapper->getByFileId($fileId);
$this->updateSignatureFlowIfAllowed($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
} catch (\Throwable) { } catch (\Throwable) {
} }
@ -118,27 +120,45 @@ class RequestSignatureService {
$file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN); $file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN);
} }
if (isset($data['signatureFlow']) && is_string($data['signatureFlow'])) { $this->setSignatureFlow($file, $data);
try {
$signatureFlow = \OCA\Libresign\Enum\SignatureFlow::from($data['signatureFlow']);
$file->setSignatureFlowEnum($signatureFlow);
} catch (\ValueError) {
$this->setSignatureFlowFromGlobalConfig($file);
}
} else {
$this->setSignatureFlowFromGlobalConfig($file);
}
$this->setDocMdpLevelFromGlobalConfig($file); $this->setDocMdpLevelFromGlobalConfig($file);
$this->fileMapper->insert($file); $this->fileMapper->insert($file);
return $file; return $file;
} }
private function setSignatureFlowFromGlobalConfig(FileEntity $file): void { private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void {
$globalFlowValue = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::PARALLEL->value); $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
$globalFlow = SignatureFlow::from($globalFlowValue); $adminForcedConfig = $adminFlow !== SignatureFlow::NONE->value;
$file->setSignatureFlowEnum($globalFlow);
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 { 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_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('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER));
$this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); $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'); return new TemplateResponse(Application::APP_ID, 'admin_settings');
} }

View file

@ -6679,6 +6679,11 @@
"format": "int64", "format": "int64",
"nullable": true, "nullable": true,
"description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" "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", "format": "int64",
"nullable": true, "nullable": true,
"description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" "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"> @click="addSigner">
{{ t('libresign', 'Add signer') }} {{ t('libresign', 'Add signer') }}
</NcButton> </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" <Signers event="libresign:edit-signer"
@signing-order-changed="debouncedSave"> @signing-order-changed="debouncedSave">
<template #actions="{signer, closeActions}"> <template #actions="{signer, closeActions}">
@ -192,6 +206,18 @@
</NcButton> </NcButton>
</template> </template>
</NcDialog> </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> </div>
</template> </template>
<script> <script>
@ -200,11 +226,13 @@ import debounce from 'debounce'
import svgAccount from '@mdi/svg/svg/account.svg?raw' import svgAccount from '@mdi/svg/svg/account.svg?raw'
import svgEmail from '@mdi/svg/svg/email.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 svgSms from '@mdi/svg/svg/message-processing.svg?raw'
import svgWhatsapp from '@mdi/svg/svg/whatsapp.svg?raw' import svgWhatsapp from '@mdi/svg/svg/whatsapp.svg?raw'
import svgXmpp from '@mdi/svg/svg/xmpp.svg?raw' import svgXmpp from '@mdi/svg/svg/xmpp.svg?raw'
import Bell from 'vue-material-design-icons/Bell.vue' 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 Delete from 'vue-material-design-icons/Delete.vue'
import Draw from 'vue-material-design-icons/Draw.vue' import Draw from 'vue-material-design-icons/Draw.vue'
import FileDocument from 'vue-material-design-icons/FileDocument.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 NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcButton from '@nextcloud/vue/components/NcButton' import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog' import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' 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 NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IdentifySigner from '../Request/IdentifySigner.vue' import IdentifySigner from '../Request/IdentifySigner.vue'
import VisibleElements from '../Request/VisibleElements.vue'
import Signers from '../Signers/Signers.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 svgSignal from '../../../img/logo-signal-app.svg?raw'
import svgTelegram from '../../../img/logo-telegram-app.svg?raw' import svgTelegram from '../../../img/logo-telegram-app.svg?raw'
@ -262,28 +292,31 @@ export default {
name: 'RequestSignatureTab', name: 'RequestSignatureTab',
mixins: [signingOrderMixin], mixins: [signingOrderMixin],
components: { components: {
Bell,
ChartGantt,
Delete,
Draw,
FileDocument,
IdentifySigner,
Information,
MessageText,
NcActionButton, NcActionButton,
NcActionInput, NcActionInput,
NcActions, NcActions,
NcAppSidebar, NcAppSidebar,
NcAppSidebarTab, NcAppSidebarTab,
NcButton, NcButton,
NcCheckboxRadioSwitch,
NcDialog,
NcIconSvgWrapper, NcIconSvgWrapper,
NcLoadingIcon, NcLoadingIcon,
NcModal, NcModal,
NcNoteCard, NcNoteCard,
NcDialog,
Bell,
Delete,
Draw,
FileDocument,
Information,
MessageText,
OrderNumericAscending, OrderNumericAscending,
Pencil, Pencil,
Send, Send,
Signers, Signers,
IdentifySigner, SigningOrderDiagram,
VisibleElements, VisibleElements,
}, },
props: { props: {
@ -311,23 +344,61 @@ export default {
showConfirmRequestSigner: false, showConfirmRequestSigner: false,
selectedSigner: null, selectedSigner: null,
activeTab: '', activeTab: '',
preserveOrder: false,
showOrderDiagram: false,
infoIcon: svgInfo,
adminSignatureFlow: '',
lastSyncedFileId: null,
} }
}, },
computed: { computed: {
signatureFlow() { signatureFlow() {
const file = this.filesStore.getFile() 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() { isOrderedNumeric() {
return this.signatureFlow === 'ordered_numeric' 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() { showDocMdpWarning() {
return this.filesStore.isDocMdpNoChangesAllowed() && !this.filesStore.canAddSigner() return this.filesStore.isDocMdpNoChangesAllowed() && !this.filesStore.canAddSigner()
}, },
canEditSigningOrder() { canEditSigningOrder() {
return (signer) => { return (signer) => {
const minSigners = this.isAdminFlowForced ? 1 : 2
return this.isOrderedNumeric return this.isOrderedNumeric
&& this.totalSigners > 1 && this.totalSigners >= minSigners
&& this.filesStore.canSave() && this.filesStore.canSave()
&& !signer.signed && !signer.signed
} }
@ -490,12 +561,25 @@ export default {
signers(signers) { signers(signers) {
this.init(signers) this.init(signers)
}, },
'filesStore.selectedNodeId': {
handler(newFileId, oldFileId) {
if (newFileId && newFileId !== this.lastSyncedFileId) {
this.syncPreserveOrderWithFile()
this.lastSyncedFileId = newFileId
}
},
immediate: true,
},
}, },
async mounted() { async mounted() {
subscribe('libresign:edit-signer', this.editSigner) subscribe('libresign:edit-signer', this.editSigner)
this.filesStore.disableIdentifySigner() this.filesStore.disableIdentifySigner()
this.activeTab = this.userConfigStore.signer_identify_tab || '' this.activeTab = this.userConfigStore.signer_identify_tab || ''
this.adminSignatureFlow = loadState('libresign', 'signature_flow', 'none')
this.syncPreserveOrderWithFile()
}, },
beforeUnmount() { beforeUnmount() {
unsubscribe('libresign:edit-signer') unsubscribe('libresign:edit-signer')
@ -506,7 +590,11 @@ export default {
this.debouncedSave = debounce(async () => { this.debouncedSave = debounce(async () => {
try { try {
await this.filesStore.saveWithVisibleElements({ visibleElements: [] }) const file = this.filesStore.getFile()
await this.filesStore.saveWithVisibleElements({
visibleElements: [],
signatureFlow: file?.signatureFlow,
})
} catch (error) { } catch (error) {
if (error.response?.data?.ocs?.data?.message) { if (error.response?.data?.ocs?.data?.message) {
showError(error.response.data.ocs.data.message) showError(error.response.data.ocs.data.message)
@ -521,6 +609,56 @@ export default {
}, 500) }, 500)
}, },
methods: { 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) { getSvgIcon(name) {
return iconMap[`svg${name.charAt(0).toUpperCase() + name.slice(1)}`] || iconMap.svgAccount return iconMap[`svg${name.charAt(0).toUpperCase() + name.slice(1)}`] || iconMap.svgAccount
}, },
@ -789,6 +927,10 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.checkbox-radio-switch) {
margin: 8px 0;
}
.action-buttons { .action-buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -96,7 +96,14 @@ export default {
computed: { computed: {
signatureFlow() { signatureFlow() {
const file = this.filesStore.getFile() 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() { signer() {
const file = this.filesStore.getFile() const file = this.filesStore.getFile()

View file

@ -83,7 +83,14 @@ export default {
}, },
isOrderedNumeric() { isOrderedNumeric() {
const file = this.filesStore.getFile() 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() { canReorder() {
return this.filesStore.canSave() && this.signers.length > 1 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() { filesSorted() {
return this.ordered.map(key => this.files[key]) 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() 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 = { const config = {
url: generateOcsUrl('/apps/libresign/api/v1/request-signature'), url: generateOcsUrl('/apps/libresign/api/v1/request-signature'),
method: uuid || file.uuid ? 'patch' : 'post', method: uuid || file.uuid ? 'patch' : 'post',
@ -391,10 +398,12 @@ export const useFilesStore = function(...args) {
users: signers || file.signers, users: signers || file.signers,
visibleElements, visibleElements,
status: 0, status: 0,
signatureFlow: flowValue,
}, },
} }
if (uuid || file.uuid) {
if (uuid || file.uuid) {
config.data.uuid = uuid || file.uuid config.data.uuid = uuid || file.uuid
} else { } else {
config.data.file = { config.data.file = {
@ -406,8 +415,15 @@ export const useFilesStore = function(...args) {
this.addFile(data.ocs.data.data) this.addFile(data.ocs.data.data)
return data.ocs.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() 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 = { const config = {
url: generateOcsUrl('/apps/libresign/api/v1/request-signature'), url: generateOcsUrl('/apps/libresign/api/v1/request-signature'),
method: uuid || file.uuid ? 'patch' : 'post', method: uuid || file.uuid ? 'patch' : 'post',
@ -416,6 +432,7 @@ export const useFilesStore = function(...args) {
users: signers || file.signers, users: signers || file.signers,
visibleElements, visibleElements,
status, status,
signatureFlow: flowValue,
}, },
} }
@ -426,7 +443,6 @@ export const useFilesStore = function(...args) {
fileId: nodeId || this.selectedNodeId, fileId: nodeId || this.selectedNodeId,
} }
} }
const { data } = await axios(config) const { data } = await axios(config)
this.addFile(data.ocs.data.data) this.addFile(data.ocs.data.data)
return data.ocs.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 * @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
*/ */
status?: number | null; 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 * @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
*/ */
status?: number | null; 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: { methods: {
loadConfig() { loadConfig() {
try { 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.enabled = false
this.selectedFlow = this.availableFlows[0] this.selectedFlow = this.availableFlows[0]
} else { } else {

View file

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