Skip to content

Commit 47a5d9d

Browse files
committed
feat(frontend): add identify-methods and tsa policy UI definitions
1 parent 86d118a commit 47a5d9d

6 files changed

Lines changed: 592 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<div class="identify-methods-editor">
8+
<div v-if="entries.length === 0" class="identify-methods-editor__empty">
9+
{{ t('libresign', 'No identification methods available.') }}
10+
</div>
11+
12+
<div v-for="(identifyMethod, index) in entries"
13+
:key="identifyMethod.name"
14+
class="identify-methods-editor__method">
15+
<hr v-if="index !== 0">
16+
17+
<NcCheckboxRadioSwitch type="switch"
18+
:model-value="identifyMethod.enabled"
19+
@update:modelValue="onMethodToggle(index, $event)">
20+
{{ identifyMethod.friendly_name ?? identifyMethod.name }}
21+
</NcCheckboxRadioSwitch>
22+
23+
<div v-if="identifyMethod.enabled" class="identify-methods-editor__method-details">
24+
<fieldset v-if="identifyMethod.name === 'email'" class="identify-methods-editor__sub-section">
25+
<NcCheckboxRadioSwitch :model-value="Boolean(identifyMethod.can_create_account)"
26+
@update:modelValue="onCanCreateAccountToggle(index, $event)">
27+
{{ t('libresign', 'Request to create account when the user does not have an account') }}
28+
</NcCheckboxRadioSwitch>
29+
</fieldset>
30+
31+
<fieldset class="identify-methods-editor__sub-section">
32+
<legend>{{ t('libresign', 'Signature methods') }}</legend>
33+
<NcCheckboxRadioSwitch v-for="(signatureMethod, signatureMethodName) in identifyMethod.signatureMethods"
34+
:key="signatureMethodName"
35+
type="radio"
36+
:name="identifyMethod.name"
37+
:value="signatureMethodName"
38+
:model-value="identifyMethod.signatureMethodEnabled"
39+
@update:modelValue="onSignatureMethodChange(index, String($event))">
40+
{{ signatureMethod.label ?? signatureMethodName }}
41+
</NcCheckboxRadioSwitch>
42+
</fieldset>
43+
</div>
44+
</div>
45+
</div>
46+
</template>
47+
48+
<script setup lang="ts">
49+
import { computed } from 'vue'
50+
51+
import { t } from '@nextcloud/l10n'
52+
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
53+
54+
import type { EffectivePolicyValue } from '../../../../../types/index'
55+
import {
56+
normalizeIdentifyMethodsPolicy,
57+
serializeIdentifyMethodsPolicy,
58+
type IdentifyMethodPolicyEntry,
59+
} from './model'
60+
61+
defineOptions({
62+
name: 'IdentifyMethodsRuleEditor',
63+
})
64+
65+
const props = defineProps<{
66+
modelValue: EffectivePolicyValue
67+
}>()
68+
69+
const emit = defineEmits<{
70+
'update:modelValue': [value: IdentifyMethodPolicyEntry[]]
71+
}>()
72+
73+
const entries = computed(() => {
74+
const normalized = normalizeIdentifyMethodsPolicy(props.modelValue)
75+
return ensureSignatureMethodSelection(normalized)
76+
})
77+
78+
function onMethodToggle(index: number, enabled: boolean): void {
79+
const nextEntries = [...entries.value]
80+
nextEntries[index] = {
81+
...nextEntries[index],
82+
enabled,
83+
}
84+
emit('update:modelValue', serializeIdentifyMethodsPolicy(ensureSignatureMethodSelection(nextEntries).filter((entry) => entry.enabled)))
85+
}
86+
87+
function onCanCreateAccountToggle(index: number, canCreateAccount: boolean): void {
88+
const nextEntries = [...entries.value]
89+
nextEntries[index] = {
90+
...nextEntries[index],
91+
can_create_account: canCreateAccount,
92+
}
93+
emit('update:modelValue', serializeIdentifyMethodsPolicy(ensureSignatureMethodSelection(nextEntries).filter((entry) => entry.enabled)))
94+
}
95+
96+
function onSignatureMethodChange(index: number, signatureMethodName: string): void {
97+
const nextEntries = [...entries.value]
98+
nextEntries[index] = {
99+
...nextEntries[index],
100+
signatureMethodEnabled: signatureMethodName,
101+
}
102+
emit('update:modelValue', serializeIdentifyMethodsPolicy(ensureSignatureMethodSelection(nextEntries).filter((entry) => entry.enabled)))
103+
}
104+
105+
function ensureSignatureMethodSelection(entries: IdentifyMethodPolicyEntry[]): IdentifyMethodPolicyEntry[] {
106+
return entries.map((entry) => {
107+
const signatureMethodNames = Object.keys(entry.signatureMethods)
108+
if (signatureMethodNames.length === 0) {
109+
return {
110+
...entry,
111+
signatureMethodEnabled: undefined,
112+
}
113+
}
114+
115+
let selectedSignatureMethod = entry.signatureMethodEnabled
116+
if (!selectedSignatureMethod || !signatureMethodNames.includes(selectedSignatureMethod)) {
117+
selectedSignatureMethod = signatureMethodNames.find((signatureMethodName) => entry.signatureMethods[signatureMethodName]?.enabled)
118+
?? signatureMethodNames[0]
119+
}
120+
121+
const signatureMethods = Object.fromEntries(
122+
signatureMethodNames.map((signatureMethodName) => [
123+
signatureMethodName,
124+
{
125+
...entry.signatureMethods[signatureMethodName],
126+
enabled: signatureMethodName === selectedSignatureMethod,
127+
},
128+
]),
129+
)
130+
131+
return {
132+
...entry,
133+
signatureMethods,
134+
signatureMethodEnabled: selectedSignatureMethod,
135+
}
136+
})
137+
}
138+
</script>
139+
140+
<style scoped lang="scss">
141+
.identify-methods-editor {
142+
display: flex;
143+
flex-direction: column;
144+
gap: 0.5rem;
145+
}
146+
147+
.identify-methods-editor__empty {
148+
color: var(--color-text-maxcontrast);
149+
font-size: 0.9rem;
150+
}
151+
152+
.identify-methods-editor__method {
153+
display: flex;
154+
flex-direction: column;
155+
gap: 0.5rem;
156+
}
157+
158+
.identify-methods-editor__method-details {
159+
display: flex;
160+
flex-direction: column;
161+
gap: 0.5rem;
162+
}
163+
164+
.identify-methods-editor__sub-section {
165+
display: flex;
166+
flex-direction: column;
167+
gap: 0.25rem;
168+
border: 0;
169+
margin: 0 0 0 1.5rem;
170+
padding: 0;
171+
}
172+
173+
.identify-methods-editor__sub-section legend {
174+
padding: 0;
175+
margin-bottom: 0.25rem;
176+
font-weight: 600;
177+
}
178+
</style>
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { EffectivePolicyValue } from '../../../../../types/index'
7+
8+
export type IdentifyMethodSignatureMethod = {
9+
enabled?: boolean
10+
label?: string
11+
}
12+
13+
export type IdentifyMethodPolicyEntry = {
14+
name: string
15+
friendly_name?: string
16+
enabled: boolean
17+
can_create_account?: boolean
18+
mandatory?: boolean
19+
signatureMethods: Record<string, IdentifyMethodSignatureMethod>
20+
signatureMethodEnabled?: string
21+
}
22+
23+
export function normalizeIdentifyMethodsPolicy(value: EffectivePolicyValue): IdentifyMethodPolicyEntry[] {
24+
if (typeof value === 'string') {
25+
const decoded = safeJsonParse(value)
26+
if (Array.isArray(decoded)) {
27+
value = decoded as unknown as EffectivePolicyValue
28+
}
29+
}
30+
31+
if (!Array.isArray(value)) {
32+
return []
33+
}
34+
35+
const normalized: IdentifyMethodPolicyEntry[] = []
36+
37+
for (const rawEntry of value) {
38+
if (!rawEntry || typeof rawEntry !== 'object') {
39+
continue
40+
}
41+
42+
const candidate = rawEntry as Record<string, unknown>
43+
const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''
44+
if (!name) {
45+
continue
46+
}
47+
48+
const signatureMethods = normalizeSignatureMethods(candidate.signatureMethods)
49+
50+
normalized.push({
51+
name,
52+
friendly_name: typeof candidate.friendly_name === 'string' ? candidate.friendly_name : undefined,
53+
enabled: Boolean(candidate.enabled),
54+
can_create_account: candidate.can_create_account === undefined ? undefined : Boolean(candidate.can_create_account),
55+
mandatory: candidate.mandatory === undefined ? undefined : Boolean(candidate.mandatory),
56+
signatureMethods,
57+
signatureMethodEnabled: typeof candidate.signatureMethodEnabled === 'string'
58+
? candidate.signatureMethodEnabled
59+
: undefined,
60+
})
61+
}
62+
63+
return normalized
64+
}
65+
66+
export function serializeIdentifyMethodsPolicy(entries: IdentifyMethodPolicyEntry[]): IdentifyMethodPolicyEntry[] {
67+
return entries.map((entry) => {
68+
const signatureMethods: Record<string, IdentifyMethodSignatureMethod> = {}
69+
70+
for (const [signatureMethodName, signatureMethod] of Object.entries(entry.signatureMethods)) {
71+
signatureMethods[signatureMethodName] = {
72+
enabled: Boolean(signatureMethod.enabled),
73+
}
74+
}
75+
76+
const normalizedEntry: IdentifyMethodPolicyEntry = {
77+
name: entry.name,
78+
enabled: Boolean(entry.enabled),
79+
signatureMethods,
80+
}
81+
82+
if (typeof entry.friendly_name === 'string') {
83+
normalizedEntry.friendly_name = entry.friendly_name
84+
}
85+
86+
if (entry.can_create_account !== undefined) {
87+
normalizedEntry.can_create_account = Boolean(entry.can_create_account)
88+
}
89+
90+
if (entry.mandatory !== undefined) {
91+
normalizedEntry.mandatory = Boolean(entry.mandatory)
92+
}
93+
94+
if (entry.signatureMethodEnabled) {
95+
normalizedEntry.signatureMethodEnabled = entry.signatureMethodEnabled
96+
}
97+
98+
return normalizedEntry
99+
})
100+
}
101+
102+
function normalizeSignatureMethods(value: unknown): Record<string, IdentifyMethodSignatureMethod> {
103+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
104+
return {}
105+
}
106+
107+
const signatureMethods: Record<string, IdentifyMethodSignatureMethod> = {}
108+
109+
for (const [signatureMethodName, rawConfig] of Object.entries(value as Record<string, unknown>)) {
110+
if (!rawConfig || typeof rawConfig !== 'object') {
111+
continue
112+
}
113+
114+
const candidate = rawConfig as Record<string, unknown>
115+
signatureMethods[signatureMethodName] = {
116+
enabled: Boolean(candidate.enabled),
117+
label: typeof candidate.label === 'string' ? candidate.label : undefined,
118+
}
119+
}
120+
121+
return signatureMethods
122+
}
123+
124+
function safeJsonParse(value: string): unknown {
125+
try {
126+
return JSON.parse(value)
127+
} catch {
128+
return null
129+
}
130+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { t } from '@nextcloud/l10n'
7+
8+
import type { EffectivePolicyValue } from '../../../../../types/index'
9+
import type { RealPolicySettingDefinition } from '../realTypes'
10+
import IdentifyMethodsRuleEditor from './IdentifyMethodsRuleEditor.vue'
11+
import { normalizeIdentifyMethodsPolicy, serializeIdentifyMethodsPolicy } from './model'
12+
13+
export const identifyMethodsRealDefinition: RealPolicySettingDefinition = {
14+
key: 'identify_methods',
15+
title: t('libresign', 'Identification factors'),
16+
description: t('libresign', 'Ways to identify a person who will sign a document.'),
17+
supportedScopes: ['system', 'group', 'user'],
18+
editor: IdentifyMethodsRuleEditor,
19+
resolutionMode: 'precedence',
20+
createEmptyValue: () => [] as unknown as EffectivePolicyValue,
21+
normalizeDraftValue: (value: EffectivePolicyValue) => serializeIdentifyMethodsPolicy(normalizeIdentifyMethodsPolicy(value)) as unknown as EffectivePolicyValue,
22+
hasSelectableDraftValue: () => true,
23+
normalizeAllowChildOverride: (_scope, allowChildOverride: boolean) => allowChildOverride,
24+
getFallbackSystemDefault: (policyValue: EffectivePolicyValue | null | undefined, sourceScope?: string | null) => {
25+
if (sourceScope === 'system' && policyValue !== null && policyValue !== undefined) {
26+
return policyValue
27+
}
28+
29+
return [] as unknown as EffectivePolicyValue
30+
},
31+
summarizeValue: (value: EffectivePolicyValue) => {
32+
const normalized = normalizeIdentifyMethodsPolicy(value)
33+
if (normalized.length === 0) {
34+
return t('libresign', 'Default runtime behavior')
35+
}
36+
37+
const enabled = normalized.filter((entry) => entry.enabled)
38+
if (enabled.length === 0) {
39+
return t('libresign', 'No enabled identification factor')
40+
}
41+
42+
if (enabled.length <= 2) {
43+
return enabled.map((entry) => entry.friendly_name ?? entry.name).join(', ')
44+
}
45+
46+
return t('libresign', '{count} enabled factors', { count: String(enabled.length) })
47+
},
48+
formatAllowOverride: (allowChildOverride: boolean) =>
49+
allowChildOverride
50+
? t('libresign', 'Groups and users can set their own rule')
51+
: t('libresign', 'Groups and users must follow this value'),
52+
}

0 commit comments

Comments
 (0)