mirror of
https://github.com/LibreSign/libresign.git
synced 2025-12-17 13:08:48 +01:00
Merge pull request #6231 from LibreSign/feat/signing-order-visual-diagram
feat: signing order visual diagram
This commit is contained in:
commit
45806e18d7
20 changed files with 738 additions and 46 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
473
src/Components/SigningOrder/SigningOrderDiagram.vue
Normal file
473
src/Components/SigningOrder/SigningOrderDiagram.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue