Section locking (#10702)

* implement section locking functionality with role-based access control

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix formatting

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Add translations

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

---------

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-03-28 19:35:37 +07:00
committed by GitHub
parent 12d77c8194
commit 0e3cfbd949
24 changed files with 260 additions and 41 deletions
+4 -1
View File
@@ -32346,6 +32346,9 @@ importers:
../../server-plugins/card-resources:
dependencies:
'@hcengineering/activity':
specifier: workspace:^0.7.0
version: link:../../plugins/activity
'@hcengineering/card':
specifier: workspace:^0.7.0
version: link:../../plugins/card
@@ -61563,7 +61566,7 @@ snapshots:
node-loader@2.0.0(webpack@5.102.1):
dependencies:
loader-utils: 2.0.4
webpack: 5.102.1
webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4)
node-localstorage@2.2.1:
dependencies:
+2 -1
View File
@@ -83,7 +83,7 @@ import { PaletteColorIndexes } from '@hcengineering/ui/src/colors'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import { type BuildModelKey } from '@hcengineering/view'
import { createActions } from './actions'
import { definePermissions } from './permissions'
import { defineActionPermissions, definePermissions } from './permissions'
import card from './plugin'
import notification from '@hcengineering/notification'
@@ -426,6 +426,7 @@ export function createModel (builder: Builder): void {
defineTabs(builder)
definePermissions(builder)
defineActionPermissions(builder)
builder.mixin(card.class.Card, core.class.Class, view.mixin.ObjectIcon, {
component: card.component.CardIcon
+32
View File
@@ -154,3 +154,35 @@ export function definePermissions (builder: Builder): void {
card.permission.ForbidRemoveCard
)
}
export function defineActionPermissions (builder: Builder): void {
builder.createDoc(
core.class.Permission,
core.space.Model,
{
label: card.string.LockSection,
txClass: core.class.TxUpdateDoc,
objectClass: card.class.Card,
txMatch: {
'operations.$push.readonlySections': { $exists: true }
},
scope: 'space'
},
card.permission.LockSection
)
builder.createDoc(
core.class.Permission,
core.space.Model,
{
label: card.string.UnLockSection,
txClass: core.class.TxUpdateDoc,
objectClass: card.class.Card,
txMatch: {
'operations.$pull.readonlySections': { $exists: true }
},
scope: 'space'
},
card.permission.UnlockSection
)
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Karta aktualizována",
"CardCreated": "Karta vytvořena",
"MyCards": "Moje karty",
"GotoMyCards": "Přejít na moje karty"
"GotoMyCards": "Přejít na moje karty",
"LockSection": "Zamknout sekci",
"UnLockSection": "Odemknout sekci",
"SectionLocked": "Sekce {section} zamknuta",
"SectionUnlocked": "Sekce {section} odemknuta"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Karte aktualisiert",
"CardCreated": "Karte erstellt",
"MyCards": "Meine Karten",
"GotoMyCards": "Zu meinen Karten"
"GotoMyCards": "Zu meinen Karten",
"LockSection": "Sperren",
"UnLockSection": "Entsperren",
"SectionLocked": "Sektion {section} gesperrt",
"SectionUnlocked": "Sektion {section} entsperrt"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Card updated",
"CardCreated": "Card created",
"MyCards": "My cards",
"GotoMyCards": "Go to my cards"
"GotoMyCards": "Go to my cards",
"LockSection": "Lock section",
"UnLockSection": "Unlock section",
"SectionLocked": "Section {section} locked",
"SectionUnlocked": "Section {section} unlocked"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Tarjeta actualizada",
"CardCreated": "Tarjeta creada",
"MyCards": "Mis tarjetas",
"GotoMyCards": "Ir a mis tarjetas"
"GotoMyCards": "Ir a mis tarjetas",
"LockSection": "Bloquear sección",
"UnLockSection": "Desbloquear sección",
"SectionLocked": "Sección {section} bloqueada",
"SectionUnlocked": "Sección {section} desbloqueada"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Carte mise à jour",
"CardCreated": "Carte créée",
"MyCards": "Mes cartes",
"GotoMyCards": "Aller à mes cartes"
"GotoMyCards": "Aller à mes cartes",
"LockSection": "Verrouiller la section",
"UnLockSection": "Déverrouiller la section",
"SectionLocked": "Section {section} verrouillée",
"SectionUnlocked": "Section {section} déverrouillée"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Scheda aggiornata",
"CardCreated": "Scheda creata",
"MyCards": "Le mie carte",
"GotoMyCards": "Vai alle mie carte"
"GotoMyCards": "Vai alle mie carte",
"LockSection": "Blocca sezione",
"UnLockSection": "Sblocca sezione",
"SectionLocked": "Sezione {section} bloccata",
"SectionUnlocked": "Sezione {section} sbloccata"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "カードが更新されました",
"CardCreated": "カードが作成されました",
"MyCards": "マイカード",
"GotoMyCards": "マイカードへ移動"
"GotoMyCards": "マイカードへ移動",
"LockSection": "セクションをロック",
"UnLockSection": "セクションをアンロック",
"SectionLocked": "セクション {section} ロック済み",
"SectionUnlocked": "セクション {section} アンロック済み"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Cartão atualizado",
"CardCreated": "Cartão criado",
"MyCards": "Meus cartões",
"GotoMyCards": "Ir para meus cartões"
"GotoMyCards": "Ir para meus cartões",
"LockSection": "Bloquear seção",
"UnLockSection": "Desbloquear seção",
"SectionLocked": "Seção {section} bloqueada",
"SectionUnlocked": "Seção {section} desbloqueada"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Cartão atualizado",
"CardCreated": "Cartão criado",
"MyCards": "Meus cartões",
"GotoMyCards": "Ir para meus cartões"
"GotoMyCards": "Ir para meus cartões",
"LockSection": "Bloquear seção",
"UnLockSection": "Desbloquear seção",
"SectionLocked": "Seção {section} bloqueada",
"SectionUnlocked": "Seção {section} desbloqueada"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Карточка обновлена",
"CardCreated": "Карточка создана",
"MyCards": "Мои карты",
"GotoMyCards": "Перейти к моим картам"
"GotoMyCards": "Перейти к моим картам",
"LockSection": "Заблокировать секцию",
"UnLockSection": "Разблокировать секцию",
"SectionLocked": "Секция {section} заблокирована",
"SectionUnlocked": "Секция {section} разблокирована"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "Kart güncellendi",
"CardCreated": "Kart oluşturuldu",
"MyCards": "Benim kartlarım",
"GotoMyCards": "Benim kartlarıma git"
"GotoMyCards": "Benim kartlarıma git",
"LockSection": "Bölümü kilitle",
"UnLockSection": "Bölümü kilitle",
"SectionLocked": "Bölüm {section} kilitli",
"SectionUnlocked": "Bölüm {section} kilidi açıldı"
}
}
+5 -1
View File
@@ -74,6 +74,10 @@
"CardUpdated": "卡片已更新",
"CardCreated": "卡片已创建",
"MyCards": "我的卡片",
"GotoMyCards": "转到我的卡片"
"GotoMyCards": "转到我的卡片",
"LockSection": "锁定部分",
"UnLockSection": "解锁部分",
"SectionLocked": "部分 {section} 已锁定",
"SectionUnlocked": "部分 {section} 已解锁"
}
}
@@ -29,6 +29,11 @@
import CardAttributes from './CardAttributes.svelte'
import { AccountRole, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import CardIcon from './CardIcon.svelte'
import { canLockSection, canUnlockSection } from '../utils'
import { permissionsStore } from '@hcengineering/contact-resources'
import Lock from './icons/Lock.svelte'
import Unlock from './icons/Unlock.svelte'
import card from '../plugin'
export let value: Card
export let readonly: boolean = false
@@ -49,6 +54,16 @@
export function expand (): void {
isCollapsed = false
}
$: isLocked = value.readonlySections?.includes(value._class) ?? false
$: canLock = canLockSection(value.space, $permissionsStore)
$: canUnlock = canUnlockSection(value.space, $permissionsStore)
async function toggleLock (ev: MouseEvent): Promise<void> {
ev.stopPropagation()
const op = isLocked ? '$pull' : '$push'
await client.update(value, { [op]: { readonlySections: value._class } })
}
</script>
<div class="header flex flex-gap-2">
@@ -57,8 +72,19 @@
<Label {label} />
<Chevron expanded={!isCollapsed} outline fill={'var(--content-color)'} />
</div>
{#if hasAccountRole(getCurrentAccount(), AccountRole.Maintainer)}
<div class="btns">
<div class="btns">
{#if (isLocked && canUnlock) || (!isLocked && canLock)}
<div class="lock-btn">
<Button
icon={isLocked ? Unlock : Lock}
kind={'link'}
size={'medium'}
showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }}
on:click={toggleLock}
/>
</div>
{/if}
{#if hasAccountRole(getCurrentAccount(), AccountRole.Maintainer)}
<Button
icon={IconAdd}
kind={'link'}
@@ -84,8 +110,8 @@
navigate(loc)
}}
/>
</div>
{/if}
{/if}
</div>
</div>
<ExpandCollapse isExpanded={!isCollapsed}>
<CardAttributes object={value} _class={value._class} {readonly} {ignoreKeys} {fourRows} showCollaborators />
@@ -116,6 +142,14 @@
display: flex;
align-items: center;
visibility: hidden;
.lock-btn {
opacity: 0.5;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
}
</style>
@@ -30,7 +30,12 @@
themeStore,
tooltip
} from '@hcengineering/ui'
import { permissionsStore } from '@hcengineering/contact-resources'
import { canLockSection, canUnlockSection } from '../utils'
import CardAttributes from './CardAttributes.svelte'
import Lock from './icons/Lock.svelte'
import Unlock from './icons/Unlock.svelte'
import card from '../plugin'
export let value: Card
export let tag: Tag
@@ -54,6 +59,16 @@
$: color = getPlatformColorDef(tag.background ?? 0, $themeStore.dark).color
$: isEditable = h.hasMixin(tag, setting.mixin.Editable) && h.as(tag, setting.mixin.Editable).value
$: isLocked = value.readonlySections?.includes(tag._id) ?? false
$: canLock = canLockSection(value.space, $permissionsStore)
$: canUnlock = canUnlockSection(value.space, $permissionsStore)
async function toggleLock (ev: MouseEvent): Promise<void> {
ev.stopPropagation()
const op = isLocked ? '$pull' : '$push'
await client.update(value, { [op]: { readonlySections: tag._id } })
}
</script>
<div class="header flex flex-gap-2">
@@ -68,8 +83,19 @@
</span>
<Chevron expanded={!isCollapsed} outline fill={'var(--content-color'} />
</div>
{#if hasAccountRole(getCurrentAccount(), AccountRole.Maintainer) && isEditable}
<div class="btns">
<div class="btns">
{#if (isLocked && canUnlock) || (!isLocked && canLock)}
<div class="lock-btn">
<Button
icon={isLocked ? Unlock : Lock}
kind={'link'}
size={'medium'}
showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }}
on:click={toggleLock}
/>
</div>
{/if}
{#if hasAccountRole(getCurrentAccount(), AccountRole.Maintainer) && isEditable}
<Button
icon={IconAdd}
kind={'link'}
@@ -95,8 +121,8 @@
navigate(loc)
}}
/>
</div>
{/if}
{/if}
</div>
</div>
<ExpandCollapse isExpanded={!isCollapsed}>
<CardAttributes object={value} _class={tag._id} to={tag.extends} {readonly} {ignoreKeys} />
@@ -134,6 +160,14 @@
display: flex;
align-items: center;
visibility: hidden;
.lock-btn {
opacity: 0.5;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
}
</style>
@@ -0,0 +1,12 @@
<script lang="ts">
import { IconSize } from '@hcengineering/ui'
export let size: IconSize
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.69141 14.6338H11.3018C12.2178 14.6338 12.6689 14.1826 12.6689 13.1914V8.08496C12.6689 7.18945 12.293 6.72461 11.541 6.64941V4.96094C11.541 2.36328 9.81152 1.1123 7.99316 1.1123C6.18164 1.1123 4.45215 2.36328 4.45215 4.96094V6.67676C3.74805 6.78613 3.32422 7.2373 3.32422 8.08496V13.1914C3.32422 14.1826 3.77539 14.6338 4.69141 14.6338ZM5.75098 4.83105C5.75098 3.22461 6.76953 2.35645 7.99316 2.35645C9.2168 2.35645 10.2422 3.22461 10.2422 4.83105V6.64258L5.75098 6.64941V4.83105Z"
/>
</svg>
@@ -0,0 +1,12 @@
<script lang="ts">
import { IconSize } from '@hcengineering/ui'
export let size: IconSize
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.33301 14.6338H8.94336C9.85938 14.6338 10.3105 14.1826 10.3105 13.1914V8.08496C10.3105 7.2168 9.95508 6.75195 9.24414 6.65625V4.83105C9.24414 3.22461 10.2627 2.35645 11.4863 2.35645C12.71 2.35645 13.7354 3.22461 13.7354 4.83105V6.28711C13.7354 6.76562 14.0156 7.01855 14.3848 7.01855C14.7471 7.01855 15.0273 6.7793 15.0273 6.28711V4.96094C15.0273 2.36328 13.2979 1.1123 11.4863 1.1123C9.6748 1.1123 7.94531 2.36328 7.94531 4.96094V6.64258L2.44922 6.64941C1.5332 6.64941 0.96582 7.10742 0.96582 8.08496V13.1914C0.96582 14.1826 1.41699 14.6338 2.33301 14.6338Z"
/>
</svg>
+3 -1
View File
@@ -73,7 +73,9 @@ export default mergeIds(cardId, card, {
ForbidRemoveCard: '' as Ref<Permission>,
ForbidUpdateCard: '' as Ref<Permission>,
ForbidAddTag: '' as Ref<Permission>,
ForbidRemoveTag: '' as Ref<Permission>
ForbidRemoveTag: '' as Ref<Permission>,
LockSection: '' as Ref<Permission>,
UnlockSection: '' as Ref<Permission>
},
sectionComponent: {
AttachmentsSection: '' as AnyComponent,
+17
View File
@@ -16,6 +16,7 @@ import { Analytics } from '@hcengineering/analytics'
import { type Card, CardEvents, cardId, type CardSpace, type MasterTag, type Tag } from '@hcengineering/card'
import { chatId } from '@hcengineering/chat'
import communication from '@hcengineering/communication'
import { type PermissionsStore } from '@hcengineering/contact'
import core, {
AccountRole,
type Class,
@@ -763,3 +764,19 @@ export async function canGetSpaceAccessPublicLink (doc?: Doc | Doc[]): Promise<b
return await canCopyLink(doc)
}
export function canLockSection (space: Ref<Space>, store: PermissionsStore): boolean {
if (getMetadata(core.metadata.DisablePermissions) === true) return true
if (store.whitelist.has(space)) return true
const allowed = store.ps[space]?.has(card.permission.LockSection)
if (allowed) return true
return !store.restrictedSpaces.has(space)
}
export function canUnlockSection (space: Ref<Space>, store: PermissionsStore): boolean {
if (getMetadata(core.metadata.DisablePermissions) === true) return true
if (store.whitelist.has(space)) return true
const allowed = store.ps[space]?.has(card.permission.UnlockSection)
if (allowed) return true
return !store.restrictedSpaces.has(space)
}
+5 -1
View File
@@ -206,7 +206,11 @@ const cardPlugin = plugin(cardId, {
Favorites: '' as IntlString,
CreateCard: '' as IntlString,
Version: '' as IntlString,
Versions: '' as IntlString
Versions: '' as IntlString,
LockSection: '' as IntlString,
UnLockSection: '' as IntlString,
SectionLocked: '' as IntlString,
SectionUnlocked: '' as IntlString
},
section: {
Attachments: '' as Ref<CardSection>,
@@ -37,6 +37,7 @@
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/activity": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/setting": "workspace:^0.7.0",
"@hcengineering/card": "workspace:^0.7.0",
+36 -17
View File
@@ -13,7 +13,22 @@
// limitations under the License.
//
import activity from '@hcengineering/activity'
import card, { Card, cardId, MasterTag, Tag } from '@hcengineering/card'
import communication, { Direct } from '@hcengineering/communication'
import {
AddCollaboratorsEvent,
CardEventType,
CreatePeerEvent,
MessageEventType,
NotificationEventType,
PeerEventType,
RemoveCardEvent,
ThreadPatchEvent,
UpdateCardTypeEvent
} from '@hcengineering/communication-sdk-types'
import { CardPeer } from '@hcengineering/communication-types'
import contact, { Employee, formatName, Person } from '@hcengineering/contact'
import core, {
AccountUuid,
AnyAttribute,
@@ -42,26 +57,12 @@ import core, {
TxRemoveDoc,
TxUpdateDoc
} from '@hcengineering/core'
import { getMetadata, translate } from '@hcengineering/platform'
import { getEmployee, getPersonSpaces } from '@hcengineering/server-contact'
import serverCore, { TriggerControl } from '@hcengineering/server-core'
import setting from '@hcengineering/setting'
import { workbenchId } from '@hcengineering/workbench'
import view from '@hcengineering/view'
import {
AddCollaboratorsEvent,
CardEventType,
NotificationEventType,
PeerEventType,
RemoveCardEvent,
UpdateCardTypeEvent,
CreatePeerEvent,
ThreadPatchEvent,
MessageEventType
} from '@hcengineering/communication-sdk-types'
import { getEmployee, getPersonSpaces } from '@hcengineering/server-contact'
import contact, { Employee, formatName, Person } from '@hcengineering/contact'
import communication, { Direct } from '@hcengineering/communication'
import { CardPeer } from '@hcengineering/communication-types'
import { getMetadata } from '@hcengineering/platform'
import { workbenchId } from '@hcengineering/workbench'
async function OnAttribute (ctx: TxCreateDoc<AnyAttribute>[], control: TriggerControl): Promise<Tx[]> {
const attr = TxProcessor.createDoc2Doc(ctx[0])
@@ -433,6 +434,24 @@ async function OnCardUpdate (ctx: TxUpdateDoc<Card>[], control: TriggerControl):
res.push(...(await updatePeers(control, doc, updateTx)))
await updateCollaborators(control, updateTx.operations, doc._class, doc, updateTx.modifiedBy)
const push = (updateTx.operations as any).$push?.readonlySections as Ref<Class<Doc>> | undefined
const pull = (updateTx.operations as any).$pull?.readonlySections as Ref<Class<Doc>> | undefined
const sectionId = push ?? pull
if (sectionId !== undefined) {
const sectionClass = control.hierarchy.getClass(sectionId)
const label = sectionClass?.label ?? sectionId
const section = await translate(label, {})
res.push(
control.txFactory.createTxCreateDoc(activity.class.ActivityInfoMessage, doc.space, {
attachedTo: doc._id,
attachedToClass: doc._class,
message: push !== undefined ? card.string.SectionLocked : card.string.SectionUnlocked,
props: { section },
collection: 'activity'
})
)
}
return res
}