Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-04-06 14:57:59 +07:00
134 changed files with 3064 additions and 221 deletions
+3
View File
@@ -40499,6 +40499,9 @@ importers:
'@typescript-eslint/parser':
specifier: ^6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
cross-env:
specifier: ~7.0.3
version: 7.0.3
esbuild:
specifier: ^0.25.10
version: 0.25.12
+3 -1
View File
@@ -21,7 +21,9 @@ const mockApp = {
}
return `/mock/${name}`
}),
getName: jest.fn(() => 'TestApp')
getName: jest.fn(() => 'TestApp'),
isPackaged: true,
getAppPath: jest.fn(() => '/mock/appPath')
}
jest.mock('electron', () => ({
@@ -0,0 +1,134 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { WebContents } from 'electron'
import { IpcMessage } from '../../ui/ipcMessages'
type IpcHandlerFn = (...args: any[]) => any
/** Captured ipcMain.handle / ipcMain.on callbacks for assertions. */
const ipcHandlers = new Map<string, IpcHandlerFn>()
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: (...args: any[]) => any) => {
ipcHandlers.set(channel, handler)
}),
on: jest.fn((channel: string, handler: (...args: any[]) => any) => {
ipcHandlers.set(`on:${channel}`, handler)
})
},
BrowserWindow: {
fromWebContents: jest.fn()
},
BrowserView: jest.fn()
}))
function makeWebContents (partial: {
id: number
destroyed?: boolean
findInPage?: jest.Mock
stopFindInPage?: jest.Mock
send?: jest.Mock
}): WebContents {
return {
id: partial.id,
isDestroyed: () => partial.destroyed ?? false,
findInPage: partial.findInPage ?? jest.fn(),
stopFindInPage: partial.stopFindInPage ?? jest.fn(),
send: partial.send ?? jest.fn()
} as unknown as WebContents
}
describe('findInPage main IPC', () => {
let originalConsoleError: typeof console.error
let registerFindInPageIpcHandlers: () => void
let registerFindInPageTarget: (overlayWc: WebContents, pageWc: WebContents) => void
beforeEach(async () => {
ipcHandlers.clear()
jest.resetModules()
originalConsoleError = console.error
console.error = jest.fn()
const m = await import('../../main/findInPage')
registerFindInPageIpcHandlers = m.registerFindInPageIpcHandlers
registerFindInPageTarget = m.registerFindInPageTarget
registerFindInPageIpcHandlers()
})
afterEach(() => {
console.error = originalConsoleError
})
test('FindInPage clears selection and returns -1 for empty text', async () => {
const stopFindInPage = jest.fn()
const findInPage = jest.fn()
const sender = makeWebContents({ id: 10, stopFindInPage, findInPage })
const handler = ipcHandlers.get(IpcMessage.FindInPage)
expect(handler).toBeDefined()
const result = await handler?.({ sender }, '', {})
expect(result).toBe(-1)
expect(stopFindInPage).toHaveBeenCalledWith('clearSelection')
expect(findInPage).not.toHaveBeenCalled()
})
test('FindInPage runs on page webContents when overlay is registered as invoker', async () => {
const pageFindInPage = jest.fn().mockResolvedValue(7)
const pageStop = jest.fn()
const pageWc = makeWebContents({ id: 1, findInPage: pageFindInPage, stopFindInPage: pageStop })
const overlayWc = makeWebContents({ id: 2 })
registerFindInPageTarget(overlayWc, pageWc)
const handler = ipcHandlers.get(IpcMessage.FindInPage)
const result = await handler?.({ sender: overlayWc }, 'needle', { forward: true, findNext: false })
expect(result).toBe(7)
expect(pageFindInPage).toHaveBeenCalledWith('needle', { forward: true, findNext: false })
})
test('FindInPage returns -1 when target webContents is destroyed', async () => {
const findInPage = jest.fn()
const sender = makeWebContents({ id: 20, destroyed: true, findInPage })
const handler = ipcHandlers.get(IpcMessage.FindInPage)
const result = await handler?.({ sender }, 'x', {})
expect(result).toBe(-1)
expect(findInPage).not.toHaveBeenCalled()
})
test('FindInPage returns -1 when findInPage throws', async () => {
const findInPage = jest.fn().mockImplementation(() => {
throw new Error('find failed')
})
const sender = makeWebContents({ id: 30, findInPage })
const handler = ipcHandlers.get(IpcMessage.FindInPage)
const result = await handler?.({ sender }, 'x', {})
expect(result).toBe(-1)
})
test('StopFindInPage no-ops when webContents is destroyed', async () => {
const stopFindInPage = jest.fn()
const sender = makeWebContents({ id: 40, destroyed: true, stopFindInPage })
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
await handler?.({ sender }, 'clearSelection')
expect(stopFindInPage).not.toHaveBeenCalled()
})
test('StopFindInPage forwards to resolveFindTarget', async () => {
const stopFindInPage = jest.fn()
const sender = makeWebContents({ id: 50, stopFindInPage })
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
await handler?.({ sender }, 'keepSelection')
expect(stopFindInPage).toHaveBeenCalledWith('keepSelection')
})
})
+45
View File
@@ -0,0 +1,45 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import * as nodePath from 'path'
import { getBundledUiDistPath, getFileInPublicBundledFolder } from '../../main/path'
const mockGetAppPath = jest.fn(() => '/mock/appPath')
jest.mock('electron', () => ({
app: {
getAppPath: (): string => mockGetAppPath()
}
}))
describe('path (bundled UI)', () => {
beforeEach(() => {
mockGetAppPath.mockReturnValue('/mock/appPath')
})
test('getBundledUiDistPath joins getAppPath with dist/ui', () => {
mockGetAppPath.mockReturnValue('/Applications/Huly.app/Contents/Resources/app.asar')
expect(getBundledUiDistPath()).toBe(
nodePath.join('/Applications/Huly.app/Contents/Resources/app.asar', 'dist', 'ui')
)
})
test('getFileInPublicBundledFolder nests under public', () => {
mockGetAppPath.mockReturnValue('/repo/desktop')
expect(getFileInPublicBundledFolder('AppIcon.png')).toBe(
nodePath.join('/repo/desktop', 'dist', 'ui', 'public', 'AppIcon.png')
)
})
})
+1
View File
@@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander'
program
.name('Huly')
.allowUnknownOption()
.allowExcessArguments(true)
.option('-s, --server <url>', 'Remote server URL (front). E.g. https://huly.app')
let opts: OptionValues | null = null
+15 -5
View File
@@ -28,12 +28,23 @@ export interface PackedConfig {
function readConfigFile (filePath: string): PackedConfig | undefined {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackedConfig
} catch (err) {
console.error(`Failed to read config from ${filePath}:`, err)
} catch (err: unknown) {
const code = err != null && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined
if (code !== 'ENOENT') {
console.error(`Failed to read config from ${filePath}:`, err)
}
return undefined
}
}
/** Packaged app: extraResources `config/config.json`. Dev: webpack `public/` → `dist/ui/public/`. */
function getBundledResourcesConfigPath (): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'config', 'config.json')
}
return path.join(app.getAppPath(), 'dist', 'ui', 'public', 'config', 'config.json')
}
/**
* Writes a JSON config file, logging errors.
*/
@@ -55,7 +66,7 @@ function writeConfigFile (filePath: string, config: PackedConfig): boolean {
function migrateConfigIfNeeded (): void {
try {
const userDataConfigPath = path.join(app.getPath('userData'), 'config.json')
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
const resourcesConfigPath = getBundledResourcesConfigPath()
const userDataDir = app.getPath('userData')
if (!fs.existsSync(userDataDir)) {
@@ -109,6 +120,5 @@ export function readPackedConfig (): PackedConfig | undefined {
}
// Fallback to bundled config if userData config doesn't exist
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
return readConfigFile(resourcesConfigPath)
return readConfigFile(getBundledResourcesConfigPath())
}
+5 -1
View File
@@ -15,8 +15,9 @@
import { BrowserWindow } from 'electron'
import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types'
import { OsIntegration } from './osIntegration'
import { IpcMessage } from '../ui/ipcMessages'
import { OsIntegration } from './osIntegration'
import { openFindInPageBar } from './findInPage'
export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, os: OsIntegration | undefined): void {
if (mainWindow == null) {
@@ -67,6 +68,9 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac
case 'select-all':
mainWindow.webContents.selectAll()
break
case 'find':
openFindInPageBar(mainWindow)
break
case 'reload':
mainWindow?.reload()
break
+207
View File
@@ -0,0 +1,207 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { BrowserView, BrowserWindow, ipcMain, WebContents } from 'electron'
import { IpcMessage } from '../ui/ipcMessages'
let ipcHandlersRegistered = false
/** Overlay invoker webContents id → page webContents to run `findInPage` on. */
const findPageTargetByInvokerId = new Map<number, WebContents>()
/** Page webContents id → overlay that should receive `found-in-page` IPC. */
const findResultRecipientByPageId = new Map<number, WebContents>()
const pagesWithFoundInPageListener = new WeakSet<WebContents>()
const overlayViewsByWindowId = new Map<number, BrowserView>()
const overlayVisibleByWindowId = new Map<number, boolean>()
const OVERLAY_WIDTH = 392
const OVERLAY_HEIGHT = 64
function resolveFindTarget (sender: WebContents): WebContents {
return findPageTargetByInvokerId.get(sender.id) ?? sender
}
function webContentsIfAlive (wc: WebContents): WebContents | null {
return wc.isDestroyed() ? null : wc
}
export function registerFindInPageTarget (overlayWc: WebContents, pageWc: WebContents): void {
findPageTargetByInvokerId.set(overlayWc.id, pageWc)
}
export function unregisterFindInPageTarget (overlayWc: WebContents): void {
findPageTargetByInvokerId.delete(overlayWc.id)
}
export function unregisterFindInPageForwarding (pageWc: WebContents): void {
findResultRecipientByPageId.delete(pageWc.id)
}
export function attachFindInPageResultForwarding (pageWc: WebContents, overlayWc: WebContents): void {
findResultRecipientByPageId.set(pageWc.id, overlayWc)
if (pagesWithFoundInPageListener.has(pageWc)) {
return
}
pagesWithFoundInPageListener.add(pageWc)
pageWc.on('found-in-page', (_event, result) => {
const recipient = findResultRecipientByPageId.get(pageWc.id)
if (recipient == null || recipient.isDestroyed()) {
return
}
try {
recipient.send(IpcMessage.FindInPageResult, result)
} catch (err) {
console.error('[find] forward found-in-page failed:', err)
}
})
}
function layoutFindOverlayBounds (win: BrowserWindow, view: BrowserView, visible: boolean): void {
overlayVisibleByWindowId.set(win.id, visible)
if (view.webContents.isDestroyed()) {
return
}
if (!visible || win.isDestroyed()) {
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
return
}
const b = win.getContentBounds()
view.setBounds({
x: Math.max(0, b.width - OVERLAY_WIDTH - 12),
y: 12,
width: OVERLAY_WIDTH,
height: OVERLAY_HEIGHT
})
}
export function registerFindOverlayView (win: BrowserWindow, view: BrowserView): void {
overlayViewsByWindowId.set(win.id, view)
}
export function unregisterFindOverlayView (win: BrowserWindow): void {
overlayViewsByWindowId.delete(win.id)
overlayVisibleByWindowId.delete(win.id)
}
export function openFindInPageBar (win: BrowserWindow | undefined): void {
if (win == null || win.isDestroyed()) {
return
}
const view = overlayViewsByWindowId.get(win.id)
if (view == null || view.webContents.isDestroyed()) {
try {
if (!win.webContents.isDestroyed()) {
win.webContents.send(IpcMessage.OpenFindBar)
}
} catch (err) {
console.error('[find] openFindBar fallback send failed:', err)
}
return
}
try {
layoutFindOverlayBounds(win, view, true)
view.webContents.send(IpcMessage.OpenFindBar)
} catch (err) {
console.error('[find] openFindBar overlay send failed:', err)
}
}
export function attachResizeRelayoutFindOverlay (win: BrowserWindow): void {
const onResize = (): void => {
if (win.isDestroyed()) {
return
}
if (overlayVisibleByWindowId.get(win.id) !== true) {
return
}
const view = overlayViewsByWindowId.get(win.id)
if (view == null || view.webContents.isDestroyed()) {
return
}
layoutFindOverlayBounds(win, view, true)
}
win.on('resize', onResize)
}
export function registerFindInPageIpcHandlers (): void {
if (ipcHandlersRegistered) {
return
}
ipcHandlersRegistered = true
ipcMain.handle(IpcMessage.FindInPage, async (event, text: string, options?: { forward?: boolean, findNext?: boolean, matchCase?: boolean, wordStart?: boolean, medialCapitalAsWordStart?: boolean }) => {
try {
const wc = webContentsIfAlive(resolveFindTarget(event.sender))
if (wc == null) {
return -1
}
if (text === '') {
wc.stopFindInPage('clearSelection')
return -1
}
return await Promise.resolve(wc.findInPage(text, options ?? {}))
} catch (err) {
console.error('[find] findInPage handler failed:', err)
return -1
}
})
ipcMain.handle(IpcMessage.StopFindInPage, async (event, action: 'clearSelection' | 'keepSelection' | 'activateSelection') => {
try {
const wc = webContentsIfAlive(resolveFindTarget(event.sender))
if (wc == null) {
return
}
wc.stopFindInPage(action)
} catch (err) {
console.error('[find] stopFindInPage handler failed:', err)
}
})
ipcMain.on(IpcMessage.FindOverlayLayout, (event, visible: boolean) => {
try {
const win = BrowserWindow.fromWebContents(event.sender)
if (win == null || win.isDestroyed()) {
return
}
const view = overlayViewsByWindowId.get(win.id)
if (view == null || view.webContents.isDestroyed()) {
return
}
layoutFindOverlayBounds(win, view, visible)
} catch (err) {
console.error('[find] FindOverlayLayout handler failed:', err)
}
})
}
export function attachFindShortcutToWebContents (wc: WebContents, openFindBar: () => void): void {
wc.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') {
return
}
const mod = input.control || input.meta
if (!mod || input.alt) {
return
}
const key = input.key.toLowerCase()
if (key !== 'f' || input.shift) {
return
}
event.preventDefault()
openFindBar()
})
}
+110
View File
@@ -0,0 +1,110 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { BrowserView, BrowserWindow } from 'electron'
import path from 'path'
import {
attachFindInPageResultForwarding,
attachFindShortcutToWebContents,
attachResizeRelayoutFindOverlay,
openFindInPageBar,
registerFindInPageTarget,
registerFindOverlayView,
unregisterFindInPageForwarding,
unregisterFindInPageTarget,
unregisterFindOverlayView
} from './findInPage'
import { getBundledUiDistPath } from './path'
function destroyBrowserViewSafe (view: BrowserView): void {
try {
if (view.webContents.isDestroyed()) {
return
}
const destroy = (view.webContents as { destroy?: () => void }).destroy
destroy?.()
} catch {
/* ignore */
}
}
/**
* Hosts the find UI in a separate BrowserView so `webContents.findInPage` on the
* main page does not match the query typed in the find field.
*
* If the overlay cannot be loaded, the window still works: Cmd/Ctrl+F becomes a
* no-op for the overlay (main page may still receive `OpenFindBar` if anything listens).
*/
export async function setupFindInPageOverlayForWindow (win: BrowserWindow, sessionPartition: string, preloadScriptPath: string): Promise<void> {
const pageWc = win.webContents
const overlayHtmlPath = path.join(getBundledUiDistPath(), 'find-in-page-overlay.html')
const view = new BrowserView({
webPreferences: {
devTools: true,
sandbox: false,
nodeIntegration: true,
partition: sessionPartition,
preload: preloadScriptPath
}
})
const openFind = (): void => {
openFindInPageBar(win)
}
try {
await view.webContents.loadFile(overlayHtmlPath)
} catch (err) {
console.error('[find] Overlay failed to load; find bar disabled for this window:', overlayHtmlPath, err)
destroyBrowserViewSafe(view)
attachFindShortcutToWebContents(pageWc, openFind)
return
}
if (win.isDestroyed() || pageWc.isDestroyed()) {
destroyBrowserViewSafe(view)
return
}
try {
win.addBrowserView(view)
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
registerFindOverlayView(win, view)
attachResizeRelayoutFindOverlay(win)
registerFindInPageTarget(view.webContents, pageWc)
attachFindInPageResultForwarding(pageWc, view.webContents)
attachFindShortcutToWebContents(pageWc, openFind)
attachFindShortcutToWebContents(view.webContents, openFind)
win.on('close', () => {
unregisterFindOverlayView(win)
if (!view.webContents.isDestroyed()) {
unregisterFindInPageTarget(view.webContents)
}
unregisterFindInPageForwarding(pageWc)
})
} catch (err) {
console.error('[find] Overlay registration failed; find bar disabled for this window:', err)
try {
win.removeBrowserView(view)
} catch {
/* ignore */
}
unregisterFindOverlayView(win)
destroyBrowserViewSafe(view)
attachFindShortcutToWebContents(pageWc, openFind)
}
}
+6 -2
View File
@@ -14,8 +14,12 @@
//
import { app } from 'electron'
import path from 'path'
import * as nodePath from 'path'
export function getBundledUiDistPath (): string {
return nodePath.join(app.getAppPath(), 'dist', 'ui')
}
export function getFileInPublicBundledFolder (fileName: string): string {
return path.join(app.getAppPath(), 'dist', 'ui', 'public', fileName)
return nodePath.join(getBundledUiDistPath(), 'public', fileName)
}
+13 -1
View File
@@ -13,8 +13,9 @@
// limitations under the License.
//
import { Menu, MenuItemConstructorOptions } from 'electron'
import { BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron'
import { Command, CommandOpenSettings, CommandSelectWorkspace, CommandLogout } from '../ui/types'
import { openFindInPageBar } from './findInPage'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
@@ -40,6 +41,17 @@ export const addMenus = (sendCommand: (cmd: Command, ...args: any[]) => void): v
{ role: isMac ? 'close' : 'quit' }
]
},
{
label: 'Search',
submenu: [
{
label: 'Find…',
click: (_item, browserWindow) => {
openFindInPageBar(browserWindow as BrowserWindow | undefined)
}
}
]
},
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' }
+14
View File
@@ -26,6 +26,8 @@ import { Config, MenuBarAction, NotificationParams, JumpListSpares, CommandClose
import { getOptions } from './args'
import { addMenus } from './standardMenu'
import { dispatchMenuBarAction } from './customMenu'
import { registerFindInPageIpcHandlers } from './findInPage'
import { setupFindInPageOverlayForWindow } from './findInPageOverlayHost'
import { addPermissionHandlers } from './permissions'
import autoUpdater from './updater'
import { generateId } from '@hcengineering/core'
@@ -163,6 +165,11 @@ function runTheApp (): void {
}
setupWindowTitleBar(windowOptions)
const childWindow = new BrowserWindow(windowOptions)
try {
await setupFindInPageOverlayForWindow(childWindow, sessionPartition, preloadScriptPath)
} catch (err) {
log.error('Find overlay setup failed (child window)', err)
}
await childWindow.loadFile(containerPagePath)
hookOpenWindow(childWindow)
})()
@@ -263,6 +270,11 @@ function runTheApp (): void {
}
setupWindowTitleBar(windowOptions)
mainWindow = new BrowserWindow(windowOptions)
try {
await setupFindInPageOverlayForWindow(mainWindow, sessionPartition, preloadScriptPath)
} catch (err) {
log.error('Find overlay setup failed (main window)', err)
}
app.dock?.setIcon(nativeImage.createFromPath(iconKey))
if (isDev) {
mainWindow.webContents.openDevTools()
@@ -382,6 +394,8 @@ function runTheApp (): void {
showSelectAll: false
})
registerFindInPageIpcHandlers()
ipcMain.on(IpcMessage.SetBadge, (_event: any, badge: number) => {
app.dock?.setBadge(badge > 0 ? `${badge}` : '')
app.badgeCount = badge
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title></title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
</style>
</head>
<body>
</body>
</html>
+427
View File
@@ -0,0 +1,427 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { IPCMainExposed } from './types'
const DEBOUNCE_MS = 300
/**
* Find UI is loaded in a separate BrowserView (see `findInPageOverlayHost.ts`) so
* `findInPage` on the page webContents does not search the query in this input.
* Shadow DOM keeps chrome text minimal. Match counts are not shown.
*
* Chromium moves focus to the matched text in the **page** webContents after
* `findInPage`, so the overlay stops receiving keystrokes unless we put focus back
* on the field after each search (caret is restored from before the call; we do not
* steal focus on arbitrary blur — only after our own `findInPage`).
*/
export function setupDesktopFindInPageBar (electronApi: IPCMainExposed): void {
const host = document.createElement('div')
host.id = 'desktop-find-bar-host'
host.style.display = 'none'
const shadow = host.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = shadowStyles
const root = document.createElement('div')
root.className = 'bar'
root.setAttribute('role', 'search')
const input = document.createElement('input')
input.className = 'field'
input.type = 'text'
input.placeholder = ''
input.autocomplete = 'off'
input.spellcheck = false
input.setAttribute('aria-label', 'Find in page')
const nav = document.createElement('div')
nav.className = 'nav'
nav.setAttribute('role', 'group')
nav.setAttribute('aria-label', 'Find matches')
const prevBtn = document.createElement('button')
prevBtn.type = 'button'
prevBtn.className = 'icon-btn prev'
prevBtn.setAttribute('aria-label', 'Previous match')
prevBtn.title = 'Previous (Shift+Enter)'
const nextBtn = document.createElement('button')
nextBtn.type = 'button'
nextBtn.className = 'icon-btn next'
nextBtn.setAttribute('aria-label', 'Next match')
nextBtn.title = 'Next (Enter)'
nav.appendChild(prevBtn)
nav.appendChild(nextBtn)
const closeBtn = document.createElement('button')
closeBtn.type = 'button'
closeBtn.className = 'icon-btn close-x'
closeBtn.setAttribute('aria-label', 'Close')
closeBtn.title = 'Close'
root.appendChild(input)
root.appendChild(nav)
root.appendChild(closeBtn)
shadow.appendChild(style)
shadow.appendChild(root)
document.body.appendChild(host)
let visible = false
let debounceTimer: ReturnType<typeof setTimeout> | undefined
function getQuery (): string {
return input.value.trim()
}
function updateNavState (): void {
const q = getQuery()
prevBtn.disabled = q === ''
nextBtn.disabled = q === ''
}
function show (): void {
const openingFromHidden = !visible
visible = true
host.style.display = 'block'
try {
electronApi.notifyFindOverlayLayout(true)
} catch {
/* ignore — overlay hit area may be wrong but avoid breaking the window */
}
input.focus()
if (openingFromHidden) {
input.select()
}
updateNavState()
}
function hide (): void {
visible = false
host.style.display = 'none'
prevBtn.disabled = true
nextBtn.disabled = true
try {
electronApi.notifyFindOverlayLayout(false)
} catch {
/* ignore */
}
void electronApi.stopFindInPage('clearSelection').catch(() => {})
}
async function find (text: string, options?: { findNext?: boolean, forward?: boolean }): Promise<void> {
const selStart = input.selectionStart ?? input.value.length
const selEnd = input.selectionEnd ?? input.value.length
try {
await electronApi.findInPage(text, {
forward: options?.forward ?? true,
findNext: options?.findNext ?? false
})
} catch {
return
}
if (!visible) {
return
}
// Page view steals focus when a match is highlighted; without this, further typing
// never reaches the input and scheduleFind appears to "stop working".
input.focus({ preventScroll: true })
const max = input.value.length
try {
input.setSelectionRange(Math.min(selStart, max), Math.min(selEnd, max))
} catch {
/* ignored */
}
}
function runFindNext (forward: boolean): void {
const q = getQuery()
if (q === '') {
return
}
void find(q, { findNext: true, forward })
}
function scheduleFind (): void {
if (debounceTimer !== undefined) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
debounceTimer = undefined
const q = getQuery()
if (q === '') {
void electronApi.stopFindInPage('clearSelection').catch(() => {})
updateNavState()
return
}
void find(q, { findNext: false, forward: true })
}, DEBOUNCE_MS)
}
electronApi.onOpenFindBar(() => {
show()
if (getQuery() !== '') {
scheduleFind()
}
})
input.addEventListener('input', () => {
scheduleFind()
updateNavState()
})
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (getQuery() === '') {
return
}
runFindNext(!e.shiftKey)
return
}
if (e.key === 'Escape') {
e.preventDefault()
hide()
}
})
prevBtn.addEventListener('click', () => {
runFindNext(false)
})
nextBtn.addEventListener('click', () => {
runFindNext(true)
})
closeBtn.addEventListener('click', () => {
hide()
})
document.addEventListener(
'keydown',
(e) => {
if (!visible || e.key !== 'Escape') {
return
}
const t = e.target as Node
if (!root.contains(t)) {
return
}
e.stopPropagation()
},
true
)
}
/** Left-pointing chevron; `.next::after` mirrors with `scaleX(-1)` for identical vertical alignment. */
const chevronMaskUrl =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M15.41 16.59 10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z'/%3E%3C/svg%3E\")"
/** Close (×); same mask + `currentColor` treatment as `.prev` / `.next`. */
const closeIconMaskUrl =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E\")"
const shadowStyles = `
:host {
all: initial;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.bar {
position: fixed;
top: 12px;
right: 12px;
z-index: 2147483647;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
color: #1a1a1a;
font-size: 13px;
}
@media (prefers-color-scheme: dark) {
.bar {
background: rgba(42, 42, 46, 0.96);
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08);
color: #e8e8e8;
}
}
:host-context([data-theme='theme-dark']) .bar {
background: rgba(42, 42, 46, 0.96);
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08);
color: #e8e8e8;
}
.field {
width: 200px;
padding: 7px 10px;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.35;
outline: none;
box-sizing: border-box;
}
@media (prefers-color-scheme: dark) {
.field {
border-color: rgba(255, 255, 255, 0.16);
}
}
:host-context([data-theme='theme-dark']) .field {
border-color: rgba(255, 255, 255, 0.16);
}
.field:focus {
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 42%, rgba(0, 0, 0, 0.2));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-color, #0b74da) 35%, transparent);
}
@media (prefers-color-scheme: dark) {
.field:focus {
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22));
}
}
:host-context([data-theme='theme-dark']) .field:focus {
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22));
}
.field::placeholder {
color: transparent;
}
.nav {
display: flex;
align-items: center;
gap: 2px;
padding-left: 4px;
margin-left: 2px;
border-left: 1px solid rgba(0, 0, 0, 0.08);
}
@media (prefers-color-scheme: dark) {
.nav {
border-left-color: rgba(255, 255, 255, 0.12);
}
}
:host-context([data-theme='theme-dark']) .nav {
border-left-color: rgba(255, 255, 255, 0.12);
}
.icon-btn {
position: relative;
box-sizing: border-box;
width: 30px;
height: 30px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: inherit;
opacity: 0.72;
cursor: pointer;
flex-shrink: 0;
}
.icon-btn.prev,
.icon-btn.next,
.icon-btn.close-x {
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover:not(:disabled) {
opacity: 1;
background: rgba(0, 0, 0, 0.05);
}
@media (prefers-color-scheme: dark) {
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
}
:host-context([data-theme='theme-dark']) .icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.icon-btn:disabled {
opacity: 0.28;
cursor: default;
}
.prev::after,
.next::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
transform: translate(-50%, -50%);
background-color: currentColor;
-webkit-mask-image: ${chevronMaskUrl};
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-image: ${chevronMaskUrl};
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
pointer-events: none;
}
.next::after {
transform: translate(-50%, -50%) scaleX(-1);
}
.close-x::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 18px;
height: 18px;
margin: 0;
padding: 0;
transform: translate(-50%, -50%);
background-color: currentColor;
-webkit-mask-image: ${closeIconMaskUrl};
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-image: ${closeIconMaskUrl};
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
pointer-events: none;
}
`
+25
View File
@@ -0,0 +1,25 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { setupDesktopFindInPageBar } from './findInPageBar'
import { ipcMainExposed } from './typesUtils'
window.addEventListener('DOMContentLoaded', () => {
try {
setupDesktopFindInPageBar(ipcMainExposed())
} catch (err) {
console.error('[find] Overlay UI failed to initialize:', err)
}
})
+6 -1
View File
@@ -43,5 +43,10 @@ export const IpcMessage = {
OnDeepLinkHandler: 'on-deep-link-handler',
HandleNotificationNavigation: 'handle-notification-navigation',
HandleUpdateDownloadProgress: 'handle-update-download-progress',
HandleAuth: 'handle-auth'
HandleAuth: 'handle-auth',
OpenFindBar: 'open-find-bar',
FindInPage: 'find-in-page',
StopFindInPage: 'stop-find-in-page',
FindInPageResult: 'find-in-page-result',
FindOverlayLayout: 'find-overlay-layout'
} as const
+27 -1
View File
@@ -14,7 +14,7 @@
//
import { contextBridge, ipcRenderer } from 'electron'
import { BrandingMap, Config, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types'
import { BrandingMap, Config, DesktopFoundInPageResult, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types'
import { IpcMessage } from './ipcMessages'
export function concatLink (host: string, path: string): string {
@@ -201,6 +201,32 @@ const expose: IPCMainExposed = {
},
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => {
ipcRenderer.on(IpcMessage.AutoLaunchSettingChanged, (_event, enabled: boolean) => { callback(enabled) })
},
onOpenFindBar: (callback: () => void) => {
ipcRenderer.removeAllListeners(IpcMessage.OpenFindBar)
ipcRenderer.on(IpcMessage.OpenFindBar, () => {
callback()
})
},
findInPage: async (text, options) => {
return await ipcRenderer.invoke(IpcMessage.FindInPage, text, options ?? {})
},
stopFindInPage: async (action) => {
await ipcRenderer.invoke(IpcMessage.StopFindInPage, action)
},
onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => {
ipcRenderer.removeAllListeners(IpcMessage.FindInPageResult)
ipcRenderer.on(IpcMessage.FindInPageResult, (_event, result: DesktopFoundInPageResult) => {
callback(result)
})
},
notifyFindOverlayLayout: (visible: boolean) => {
ipcRenderer.send(IpcMessage.FindOverlayLayout, visible)
}
}
contextBridge.exposeInMainWorld('electron', expose)
+1
View File
@@ -111,6 +111,7 @@ export function buildHulyApplicationMenu (minimizeToTrayEnabled: boolean, autoLa
.addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p')
.addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd')
.addSeparator(MenuEditIndex)
.addMenuItem(MenuEditIndex, 'Find', 'find', 'Ctrl+F', 'f')
.addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a')
const MenuViewIndex = 2
+25
View File
@@ -140,6 +140,7 @@ export const MenuBarActions = [
'copy',
'paste',
'delete',
'find',
'select-all',
'reload',
'force-reload',
@@ -200,7 +201,31 @@ export interface IPCMainExposed {
onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => void
isAutoLaunchEnabled: () => Promise<boolean>
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => void
onOpenFindBar: (callback: () => void) => void
findInPage: (text: string, options?: DesktopFindInPageOptions) => Promise<number>
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => Promise<void>
onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => void
/** Resize the find BrowserView hit target (main process); overlay document only. */
notifyFindOverlayLayout: (visible: boolean) => void
}
export type SendCommandDelegate = (cmd: Command, ...args: any[]) => void
export type WindowAction = () => void
/** Options passed to `webContents.findInPage` from the renderer. */
export interface DesktopFindInPageOptions {
forward?: boolean
findNext?: boolean
matchCase?: boolean
wordStart?: boolean
medialCapitalAsWordStart?: boolean
}
/** Payload mirrored from Electron `found-in-page` (subset used by the find bar UI). */
export interface DesktopFoundInPageResult {
requestId: number
activeMatchOrdinal: number
matches: number
finalUpdate: boolean
}
+10 -1
View File
@@ -126,7 +126,8 @@ module.exports = [
{
entry: {
bundle: ['@hcengineering/theme/styles/global.scss', ...['./src/ui/index.ts']],
'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts'
'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts',
findInPageOverlay: './src/ui/findInPageOverlay.ts'
},
ignoreWarnings: [
{
@@ -340,6 +341,14 @@ module.exports = [
isWindows: true
}
}),
new HtmlWebpackPlugin({
template: './src/ui/find-in-page-overlay.ejs',
filename: 'find-in-page-overlay.html',
chunks: ['findInPageOverlay'],
inject: 'body',
publicPath: '',
scriptLoading: 'blocking'
}),
...(!dev ? [new CompressionPlugin()] : []),
// new MiniCssExtractPlugin({
// filename: '[name].[id][contenthash].css'
@@ -582,6 +582,19 @@ export interface ClassPermission extends Permission {
targetClass: Ref<Class<Doc>>
}
/**
* @public
*/
export interface ModulePermissionGroup extends Doc {
application: Ref<Doc>
role: AccountRole
permissions: Ref<Permission>[]
disabledPermissions?: Ref<Permission>[]
spaceClass: Ref<Class<Space>>
enabled: boolean
order?: number
}
/**
* @public
*/
@@ -43,6 +43,7 @@ import type {
MarkupBlobRef,
MigrationState,
Mixin,
ModulePermissionGroup,
Obj,
Permission,
PersonId,
@@ -180,7 +181,8 @@ export default plugin(coreId, {
Sequence: '' as Ref<Class<Sequence>>,
CustomSequence: '' as Ref<Class<CustomSequence>>,
ClassCollaborators: '' as Ref<Class<ClassCollaborators<Doc>>>,
Collaborator: '' as Ref<Class<Collaborator>>
Collaborator: '' as Ref<Class<Collaborator>>,
ModulePermissionGroup: '' as Ref<Class<ModulePermissionGroup>>
},
icon: {
TypeString: '' as Asset,
@@ -279,6 +281,7 @@ export default plugin(coreId, {
Account: '' as IntlString,
StatusCategory: '' as IntlString,
Rank: '' as IntlString,
Order: '' as IntlString,
Members: '' as IntlString,
Owners: '' as IntlString,
Permission: '' as IntlString,
@@ -7,10 +7,14 @@ import {
import core, {
type Account,
AccountRole,
type Class,
type Doc,
type ClassPermission,
type Permission,
hasAccountRole,
type MeasureContext,
type PersonId,
type Ref,
type SessionData,
type Space,
type Tx,
@@ -22,7 +26,15 @@ import core, {
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import contact, { type Person } from '@hcengineering/contact'
/** Cached state loaded from GuestPermissionsSettings configuration document. */
interface GuestPermissionsCache {
roleAllowedClasses: Map<AccountRole, Set<Ref<Class<Doc>>>>
}
export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware {
private permissionsCache: GuestPermissionsCache | undefined = undefined
private initPromise: Promise<void> | undefined = undefined
static async create (
ctx: MeasureContext,
context: PipelineContext,
@@ -31,9 +43,86 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
return new GuestPermissionsMiddleware(context, next)
}
private async getPermissionsCache (ctx: MeasureContext): Promise<GuestPermissionsCache> {
if (this.permissionsCache !== undefined) return this.permissionsCache
if (this.initPromise === undefined) {
this.initPromise = this.loadPermissionsCache(ctx)
}
await this.initPromise
this.initPromise = undefined
return this.permissionsCache ?? { roleAllowedClasses: new Map() }
}
private async loadPermissionsCache (ctx: MeasureContext): Promise<void> {
try {
const docs = await this.findAll(ctx, core.class.ModulePermissionGroup, {}, {})
if (docs.length > 0) {
const rolePermissions = new Map<AccountRole, Set<Ref<Permission>>>()
const allPermissionIds = new Set<Ref<Permission>>()
for (const group of docs as any[]) {
if (group.enabled === false) continue
const role = ((group.role as AccountRole | undefined) ??
(Array.isArray(group.roles) && group.roles.length > 0 ? (group.roles[0] as AccountRole) : undefined) ??
AccountRole.Guest) as AccountRole
const permissions = (group.permissions ?? []) as Ref<Permission>[]
const disabled = new Set<Ref<Permission>>((group.disabledPermissions ?? []) as Ref<Permission>[])
const current = rolePermissions.get(role) ?? new Set<Ref<Permission>>()
for (const permissionId of permissions) {
if (disabled.has(permissionId)) continue
current.add(permissionId)
allPermissionIds.add(permissionId)
}
rolePermissions.set(role, current)
}
const classPermissions =
allPermissionIds.size > 0
? await this.findAll(
ctx,
core.class.ClassPermission as Ref<Class<Doc>>,
{ _id: { $in: Array.from(allPermissionIds) } } as any
)
: []
const permissionToClass = new Map<Ref<Permission>, Ref<Class<Doc>>>(
classPermissions
.map(
(permission) => [permission._id as Ref<Permission>, (permission as ClassPermission).targetClass] as const
)
.filter((entry): entry is readonly [Ref<Permission>, Ref<Class<Doc>>] => entry[1] !== undefined)
)
const roleAllowedClasses = new Map<AccountRole, Set<Ref<Class<Doc>>>>()
for (const [role, permissions] of rolePermissions.entries()) {
const allowedClasses = new Set<Ref<Class<Doc>>>()
for (const permissionId of permissions) {
const targetClass = permissionToClass.get(permissionId)
if (targetClass !== undefined) allowedClasses.add(targetClass)
}
roleAllowedClasses.set(role, allowedClasses)
}
this.permissionsCache = { roleAllowedClasses }
} else {
this.permissionsCache = { roleAllowedClasses: new Map() }
}
} catch {
this.permissionsCache = { roleAllowedClasses: new Map() }
}
}
private invalidateCacheIfNeeded (txes: Tx[]): void {
for (const tx of txes) {
if (TxProcessor.isExtendsCUD(tx._class)) {
const cudTx = tx as TxCUD<Doc>
if (cudTx.objectClass === core.class.ModulePermissionGroup) {
this.permissionsCache = undefined
return
}
}
}
}
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
const account = ctx.contextData.account
if (hasAccountRole(account, AccountRole.User)) {
this.invalidateCacheIfNeeded(txes)
return await this.provideTx(ctx, txes)
}
@@ -71,9 +160,64 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
}
}
/**
* Returns the covered-class ancestor of the objectClass if one exists in the new permissions model,
* or undefined if the class is not covered.
*/
private getCoveredClass (
objectClass: Ref<Class<Doc>>,
allowedClasses: Set<Ref<Class<Doc>>>
): Ref<Class<Doc>> | undefined {
if (allowedClasses.size === 0) return undefined
const h = this.context.hierarchy
for (const coveredClass of allowedClasses) {
if (h.isDerived(objectClass, coveredClass)) {
return coveredClass
}
}
return undefined
}
private isCreatedByAccount (doc: Doc, account: Account): boolean {
const creator = doc.createdBy
if (creator === undefined) return false
if (creator === account.primarySocialId) return true
return account.socialIds.includes(creator)
}
private async isGuestMutationOnOwnDoc (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class !== core.class.TxUpdateDoc && tx._class !== core.class.TxRemoveDoc) return false
const docs = await this.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 })
const doc = docs[0] as Doc | undefined
if (doc === undefined) return false
return this.isCreatedByAccount(doc, account)
}
private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class === core.class.TxMixin) return false
return !(await this.hasMixinAccessLevel(ctx, tx, account))
// For TxCreateDoc, check the new permission model first for covered types.
if (tx._class === core.class.TxCreateDoc) {
const cache = await this.getPermissionsCache(ctx)
const roleAllowedClasses = cache.roleAllowedClasses.get(account.role) ?? new Set<Ref<Class<Doc>>>()
const coveredClass = this.getCoveredClass(tx.objectClass, roleAllowedClasses)
if (coveredClass !== undefined) {
return false
}
// Uncovered class: fall through to TxAccessLevel check.
}
if (await this.hasMixinAccessLevel(ctx, tx, account)) {
return false
}
if (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxRemoveDoc) {
if (await this.isGuestMutationOnOwnDoc(ctx, tx, account)) {
return false
}
}
return true
}
private async isForbiddenSpaceTx (ctx: MeasureContext, tx: TxCUD<Space>, account: Account): Promise<boolean> {
@@ -0,0 +1,491 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
/**
* Tests for GuestPermissionsMiddleware
*
* Verifies that:
* - Non-guest users pass through without restriction.
* - DocGuest / ReadOnlyGuest users are always forbidden.
* - For covered classes (resolved from module allowedPermissions):
* new permission model is authoritative; TxAccessLevel is ignored.
* Create in any space → permitted.
* - For uncovered classes: TxAccessLevel fallback is used.
*/
import core, {
AccountRole,
generateId,
Hierarchy,
MeasureMetricsContext,
type Account,
type Class,
type Doc,
type MeasureContext,
type PersonId,
type Ref,
type SessionData,
type Space,
type Tx,
TxFactory
} from '@hcengineering/core'
import type { PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core'
import { GuestPermissionsMiddleware } from '../guestPermissions'
const COVERED_CLASS = 'test:class:CoveredClass' as Ref<Class<Doc>>
const UNCOVERED_CLASS = 'test:class:UncoveredClass' as Ref<Class<Doc>>
const COVERED_CLASS_PERMISSION = 'test:permission:CoveredClassPermission' as Ref<Doc>
const MODULE_PERMISSION_GROUP_CLASS = core.class.ModulePermissionGroup
const ALLOWED_SPACE = 'test:space:Allowed' as Ref<Space>
const FORBIDDEN_SPACE = 'test:space:Forbidden' as Ref<Space>
function makeAccount (role: AccountRole): Account {
return {
uuid: generateId() as any,
role,
primarySocialId: 'test' as PersonId,
socialIds: ['test' as PersonId],
fullSocialIds: []
}
}
function makeCtx (account: Account): MeasureContext<SessionData> {
const ctx = new MeasureMetricsContext('test', {}) as MeasureContext<SessionData>
ctx.contextData = {
account,
broadcast: { txes: [], queue: [], sessions: {} }
} as any
return ctx
}
type FindAllFn = (ctx: MeasureContext, _class: Ref<Class<Doc>>, query: object, options?: object) => Promise<Doc[]>
function makePipelineContext (findAll?: FindAllFn): PipelineContext {
const hierarchy = new Hierarchy()
const model = { findAllSync: (_class: any, _query: any) => [] } as any
return {
workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any },
hierarchy,
modelDb: model,
branding: null as any,
adapterManager: {} as any,
storageAdapter: {} as any,
contextVars: {},
lastTx: '',
lastHash: '',
broadcastEvent: async () => {}
} as any
}
function makeMiddleware (
findAll: FindAllFn,
nextFn?: (ctx: MeasureContext, txes: Tx[]) => Promise<TxMiddlewareResult>
): GuestPermissionsMiddleware {
const context = makePipelineContext(findAll)
const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_ctx: MeasureContext, _txes: Tx[]) => ({}) }
const mw = new (GuestPermissionsMiddleware as any)(context, next)
// Override findAll to inject our test data
mw.findAll = findAll
return mw
}
function makeCreateTx (objectClass: Ref<Class<Doc>>, objectSpace: Ref<Space>): Tx {
const factory = new TxFactory('test:account:System' as PersonId)
return factory.createTxCreateDoc(objectClass, objectSpace, {})
}
// Helper: buildGuestSettings - simulate the document that loadPermissionsCache would find
function makeGuestSettingsDoc (allowedPermissions: Ref<Doc>[], disabledPermissions?: Ref<Doc>[]): Doc {
return {
_id: generateId(),
_class: MODULE_PERMISSION_GROUP_CLASS,
space: 'core:space:Workspace' as Ref<Space>,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
application: 'test:app:tracker' as Ref<Doc>,
role: AccountRole.Guest,
permissions: allowedPermissions,
...(disabledPermissions !== undefined && disabledPermissions.length > 0 ? { disabledPermissions } : {}),
spaceClass: 'core:class:Space' as Ref<Class<Doc>>,
enabled: true
} as any
}
describe('GuestPermissionsMiddleware', () => {
// ─── Non-guest users pass through ───────────────────────────────────────────
describe('non-guest users', () => {
it('User role: passes through without restriction', async () => {
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async (ctx, txes) => {
nextCalled = true
return {}
}
)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.User))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('Owner role: passes through without restriction', async () => {
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async () => {
nextCalled = true
return {}
}
)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Owner))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
})
// ─── DocGuest / ReadOnlyGuest are always forbidden ──────────────────────────
describe('DocGuest and ReadOnlyGuest', () => {
it('DocGuest: throws Forbidden for any tx', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.DocGuest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
it('ReadOnlyGuest: throws Forbidden for any tx', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.ReadOnlyGuest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
})
// ─── New permission model (covered class) ───────────────────────────────────
describe('covered class new permission model', () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
const findAllWithSettings: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
function patchHierarchy (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
}
it('allows create for covered class in any space (TxAccessLevel is irrelevant)', async () => {
let nextCalled = false
const mw = makeMiddleware(findAllWithSettings, async () => {
nextCalled = true
return {}
})
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('also allows create in another space when class is covered', async () => {
let nextCalled = false
const mw = makeMiddleware(findAllWithSettings, async () => {
nextCalled = true
return {}
})
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('ignores permissions listed in disabledPermissions (falls back to TxAccessLevel)', async () => {
const docWithDisabled = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION], [COVERED_CLASS_PERMISSION])
const findAll: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [docWithDisabled]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
const mw = makeMiddleware(findAll)
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
})
// ─── Uncovered class falls back to TxAccessLevel ────────────────────────────
describe('uncovered class TxAccessLevel fallback', () => {
it('forbids create when class has no TxAccessLevel mixin and no GuestPermissionsSettings', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
it('allows create when TxAccessLevel.createAccessLevel === Guest (uncovered type)', async () => {
// Settings exist but UNCOVERED_CLASS is NOT in allowedPermissions-derived classes
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
let nextCalled = false
const mw = makeMiddleware(
async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
},
async () => {
nextCalled = true
return {}
}
)
// Simulate TxAccessLevel mixin via hierarchy mock on the middleware context
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
if (_class === UNCOVERED_CLASS) {
return { createAccessLevel: AccountRole.Guest }
}
return undefined
}
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
})
// ─── Precedence: covered class ignores TxAccessLevel even if it would deny ──
describe('precedence new model overrides TxAccessLevel for covered types', () => {
it('allows covered class create in allowed space regardless of missing TxAccessLevel', async () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
let nextCalled = false
const mw = makeMiddleware(
async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
},
async () => {
nextCalled = true
return {}
}
)
// Ensure hierarchy says TxAccessLevel is absent for the covered class
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => undefined
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('allows covered class create in any space even if TxAccessLevel would deny', async () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
const mw = makeMiddleware(async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
})
// TxAccessLevel would allow (createAccessLevel === Guest) should be ignored
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
if (_class === COVERED_CLASS) return { createAccessLevel: AccountRole.Guest }
return undefined
}
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
})
})
// ─── Own-document mutations for guests ───────────────────────────────────────
describe('guest update/remove own documents', () => {
const GUEST_SOCIAL = 'test:guest-social' as PersonId
function makeGuestAccountWithSocial (): Account {
return {
uuid: generateId() as any,
role: AccountRole.Guest,
primarySocialId: GUEST_SOCIAL,
socialIds: [GUEST_SOCIAL],
fullSocialIds: []
}
}
function patchHierarchyNoTxAccessLevel (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
}
it('allows guest to update document created by same account', async () => {
const objectId = generateId()
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: GUEST_SOCIAL,
createdBy: GUEST_SOCIAL
} as any
]
}
return []
}
let nextCalled = false
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
expect(nextCalled).toBe(true)
})
it('allows guest to remove document created by same account', async () => {
const objectId = generateId()
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: GUEST_SOCIAL,
createdBy: GUEST_SOCIAL
} as any
]
}
return []
}
let nextCalled = false
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxRemoveDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId)
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
expect(nextCalled).toBe(true)
})
it('forbids guest to update document created by another account', async () => {
const objectId = generateId()
const otherSocial = 'test:other-social' as PersonId
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: otherSocial,
createdBy: otherSocial
} as any
]
}
return []
}
const mw = makeMiddleware(findAll)
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
await expect(mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])).rejects.toThrow()
})
})
// ─── Cache invalidation ──────────────────────────────────────────────────────
describe('cache invalidation', () => {
it('invalidates cache when GuestPermissionsSettings is updated', async () => {
const findAll: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) {
return [makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])]
}
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
const mw = makeMiddleware(findAll)
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
// First tx as guest should load cache
const userCtx = makeCtx(makeAccount(AccountRole.User))
const settingsTx: Tx = {
_id: generateId(),
_class: core.class.TxCreateDoc,
space: core.space.Tx,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
objectId: generateId(),
objectClass: MODULE_PERMISSION_GROUP_CLASS,
objectSpace: 'core:space:Workspace' as Ref<Space>
} as any
// Owner updates settings should invalidate cache
await mw.tx(userCtx, [settingsTx])
// Cache should be cleared after settings update
expect((mw as any).permissionsCache).toBeUndefined()
})
})
})
+29
View File
@@ -61,6 +61,7 @@ import {
Model,
Prop,
ReadOnly,
TypeBoolean,
TypeCollaborativeDoc,
TypeNumber,
TypeRank,
@@ -94,6 +95,9 @@ export class TMasterTag extends TClass implements MasterTag {
color?: number
background?: number
removed?: boolean
@Prop(TypeBoolean(), card.string.SingleColumn)
singleColumn?: boolean
}
@Model(card.class.Tag, core.class.Mixin)
@@ -918,6 +922,31 @@ export function createModel (builder: Builder): void {
card.ids.ManageMasterTags
)
builder.createDoc(
core.class.ClassPermission,
core.space.Model,
{
label: card.string.AllowCreatingCards,
scope: 'space',
targetClass: card.class.Card
},
card.ids.GuestCardClassPermission
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: card.app.Card,
role: AccountRole.Guest,
permissions: [card.ids.GuestCardClassPermission],
spaceClass: card.class.CardSpace,
enabled: true,
order: 20
},
card.ids.ModulePermissionGroup
)
builder.mixin(card.class.Card, core.class.Class, view.mixin.ClassFilters, {
filters: ['space'],
ignoreKeys: ['parent']
+14
View File
@@ -69,6 +69,20 @@ export function createModel (builder: Builder): void {
chunter.app.Chunter
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: chunter.app.Chunter,
role: AccountRole.Guest,
permissions: [],
spaceClass: chunter.class.ChunterSpace,
enabled: true,
order: 30
},
chunter.ids.ModulePermissionGroup
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
+2 -1
View File
@@ -94,7 +94,8 @@ export default mergeIds(chunterId, chunter, {
Channels: '' as Ref<Viewlet>
},
ids: {
ChunterNotificationGroup: '' as Ref<NotificationGroup>
ChunterNotificationGroup: '' as Ref<NotificationGroup>,
ModulePermissionGroup: '' as Ref<Doc>
},
space: {
General: '' as Ref<Channel>,
+13
View File
@@ -1111,6 +1111,19 @@ export function createModel (builder: Builder): void {
createPrintAction(documents.class.Document, documents.action.Print)
defineSpaceType(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: documents.app.Documents,
role: AccountRole.Guest,
permissions: [],
spaceClass: documents.class.OrgSpace,
enabled: true,
order: 42
},
documents.ids.ModulePermissionGroup
)
definePermissions(builder)
defineNotifications(builder)
defineSearch(builder)
@@ -91,5 +91,8 @@ export default mergeIds(documentsId, documents, {
DocumentsNotificationGroup: '' as Ref<NotificationGroup>,
ContentNotification: '' as Ref<NotificationType>,
StateNotification: '' as Ref<NotificationType>
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
}
})
+2
View File
@@ -80,6 +80,7 @@ import {
import { definePermissions } from './permissions'
import {
TAttributePermission,
TModulePermissionGroup,
TClassPermission,
TPermission,
TRole,
@@ -136,6 +137,7 @@ export function createModel (builder: Builder): void {
TSpaceTypeDescriptor,
TRole,
TPermission,
TModulePermissionGroup,
TAttributePermission,
TClassPermission,
TAttribute,
+26
View File
@@ -17,6 +17,7 @@ import {
DOMAIN_MODEL,
DOMAIN_SPACE,
IndexKind,
type ModulePermissionGroup,
type AccountRole,
type AccountUuid,
type AnyAttribute,
@@ -46,6 +47,7 @@ import {
Prop,
TypeAccountUuid,
TypeBoolean,
TypeNumber,
TypeRef,
TypeString,
UX
@@ -193,3 +195,27 @@ export class TTxAccessLevel extends TClass implements TxAccessLevel {
updateAccessLevel?: AccountRole
isIdentity?: boolean
}
@Model(core.class.ModulePermissionGroup, core.class.Doc, DOMAIN_MODEL)
export class TModulePermissionGroup extends TDoc implements ModulePermissionGroup {
@Prop(TypeRef(core.class.Doc), core.string.AttachedTo)
application!: Ref<Doc>
@Prop(TypeString(), core.string.Roles)
role!: AccountRole
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
permissions!: Ref<Permission>[]
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
disabledPermissions?: Ref<Permission>[]
@Prop(TypeRef(core.class.Class), core.string.Class)
spaceClass!: Ref<Class<Space>>
@Prop(TypeBoolean(), core.string.Name)
enabled!: boolean
@Prop(TypeNumber(), core.string.Order)
order?: number
}
+13
View File
@@ -540,6 +540,19 @@ export function createModel (builder: Builder): void {
defineDocument(builder)
defineApplication(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: document.app.Documents,
role: AccountRole.Guest,
permissions: [],
spaceClass: document.class.Teamspace,
enabled: true,
order: 40
},
document.ids.ModulePermissionGroup
)
definePermissions(builder)
builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, {
+3
View File
@@ -61,6 +61,9 @@ export default mergeIds(documentId, document, {
Document: '' as Ref<ActionCategory>,
Other: '' as Ref<TagCategory>
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
},
string: {
ConfigDescription: '' as IntlString,
ParentDocument: '' as IntlString,
+13
View File
@@ -842,5 +842,18 @@ export function createModel (builder: Builder): void {
defineFile(builder)
defineFileVersion(builder)
defineApplication(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: drive.app.Drive,
role: AccountRole.Guest,
permissions: [],
spaceClass: drive.class.Drive,
enabled: false,
order: 60
},
drive.ids.ModulePermissionGroup
)
definePermissions(builder)
}
+3
View File
@@ -101,6 +101,9 @@ export default mergeIds(driveId, drive, {
RenameFolder: '' as ViewAction,
RestoreFileVersion: '' as ViewAction
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
},
string: {
Grid: '' as IntlString,
Name: '' as IntlString,
+14
View File
@@ -266,6 +266,20 @@ export function createModel (builder: Builder): void {
love.app.Love
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: love.app.Love,
role: AccountRole.Guest,
permissions: [],
spaceClass: love.class.Office,
enabled: true,
order: 50
},
love.ids.ModulePermissionGroup
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
+2 -1
View File
@@ -50,7 +50,8 @@ export default mergeIds(loveId, love, {
ids: {
Settings: '' as Ref<Doc>,
LoveNotificationGroup: '' as Ref<NotificationGroup>,
MeetingMinutesChatNotification: '' as Ref<NotificationType>
MeetingMinutesChatNotification: '' as Ref<NotificationType>,
ModulePermissionGroup: '' as Ref<Doc>
},
function: {
MeetingMinutesTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
+14
View File
@@ -312,6 +312,19 @@ export function createModel (builder: Builder): void {
},
setting.ids.Owners
)
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
{
name: 'guestPermissions',
label: setting.string.GuestPermissionsSettings,
icon: setting.icon.Members,
component: setting.component.GuestPermissionsSettings,
role: AccountRole.Owner,
order: 1050
},
'setting:ids:AccountPermissionsSettings' as Ref<any>
)
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
@@ -426,6 +439,7 @@ export function createModel (builder: Builder): void {
},
setting.ids.OfficeSettings
)
// Currently remove Support item from settings
// builder.createDoc(
// setting.class.SettingsCategory,
+14 -1
View File
@@ -16,7 +16,7 @@
import activity from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import core from '@hcengineering/model-core'
import { SortingOrder, type FindOptions } from '@hcengineering/core'
import { AccountRole, SortingOrder, type FindOptions } from '@hcengineering/core'
import { type Builder } from '@hcengineering/model'
import view, { createAction } from '@hcengineering/model-view'
@@ -202,6 +202,19 @@ export function createModel (builder: Builder): void {
definePresenters(builder)
defineApplication(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: testManagement.app.TestManagement,
role: AccountRole.Guest,
permissions: [],
spaceClass: testManagement.class.TestProject,
enabled: false,
order: 70
},
testManagement.ids.ModulePermissionGroup
)
builder.mixin(testManagement.class.TestCase, core.class.Class, view.mixin.ObjectIcon, {
component: testManagement.component.TestCaseStatusPresenter
+4 -1
View File
@@ -15,7 +15,7 @@
import { testManagementId } from '@hcengineering/test-management'
import testManganement from '@hcengineering/test-management-resources/src/plugin'
import type { Ref } from '@hcengineering/core'
import type { Doc, Ref } from '@hcengineering/core'
import { mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import type { ActionCategory } from '@hcengineering/view'
@@ -48,5 +48,8 @@ export default mergeIds(testManagementId, testManganement, {
TestPlanItemPresenter: '' as AnyComponent,
CreateTestRunButton: '' as AnyComponent,
RunTestPlanButton: '' as AnyComponent
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
}
})
+25
View File
@@ -657,6 +657,31 @@ export function createModel (builder: Builder): void {
order: 4000
})
builder.createDoc(
core.class.ClassPermission,
core.space.Model,
{
label: tracker.string.AllowCreatingIssues,
scope: 'space',
targetClass: tracker.class.Issue
},
tracker.ids.GuestIssueClassPermission
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: tracker.app.Tracker,
role: AccountRole.Guest,
permissions: [tracker.ids.GuestIssueClassPermission],
spaceClass: tracker.class.Project,
enabled: true,
order: 10
},
tracker.ids.ModulePermissionGroup
)
builder.createDoc(
chunter.class.ChatMessageViewlet,
core.space.Model,
+4 -1
View File
@@ -41,7 +41,8 @@ export default mergeIds(trackerId, tracker, {
ConfigDescription: '' as IntlString,
AllProjects: '' as IntlString,
MapRelatedIssues: '' as IntlString,
Extensions: '' as IntlString
Extensions: '' as IntlString,
AllowCreatingIssues: '' as IntlString
},
activity: {
StatusIcon: '' as AnyComponent,
@@ -79,6 +80,8 @@ export default mergeIds(trackerId, tracker, {
TrackerNotificationGroup: '' as Ref<NotificationGroup>,
AssigneeNotification: '' as Ref<NotificationType>,
BaseProjectType: '' as Ref<ProjectType>,
GuestIssueClassPermission: '' as Ref<Doc>,
ModulePermissionGroup: '' as Ref<Doc>,
IssueUpdatedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
IssueCreatedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
IssueRemovedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
+25
View File
@@ -898,6 +898,31 @@ function defineSettings (builder: Builder): void {
},
training.setting.Trainings
)
builder.createDoc(
core.class.ClassPermission,
core.space.Model,
{
label: training.string.AllowToTakeTraining,
scope: 'space',
targetClass: training.class.TrainingAttempt
},
training.ids.GuestTrainingAttemptClassPermission
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: training.app.Training,
role: AccountRole.Guest,
permissions: [training.ids.GuestTrainingAttemptClassPermission],
spaceClass: core.class.TypedSpace,
enabled: true,
order: 25
},
training.ids.ModulePermissionGroup
)
}
const columns = {
+4
View File
@@ -33,6 +33,10 @@ export default mergeIds(trainingId, training, {
TrainingGroup: '' as Ref<NotificationGroup>,
TrainingRequest: '' as Ref<NotificationType>
},
ids: {
GuestTrainingAttemptClassPermission: '' as Ref<Doc>,
ModulePermissionGroup: '' as Ref<Doc>
},
// TODO: Move function resources declarations to plugins/*-resources
// Currently, dependencies look like this:
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Karty",
"Content": "Obsah",
"CreateCard": "Vytvořit kartu",
"AllowCreatingCards": "Povolit vytváření karet",
"CreateMasterTag": "Vytvořit typ",
"CreateTag": "Vytvořit štítek",
"MasterTag": "Typ",
@@ -78,6 +79,9 @@
"LockSection": "Zamknout sekci",
"UnLockSection": "Odemknout sekci",
"SectionLocked": "Sekce {section} zamknuta",
"SectionUnlocked": "Sekce {section} odemknuta"
"SectionUnlocked": "Sekce {section} odemknuta",
"LayoutAuto": "Auto",
"SingleColumn": "Jeden sloupec",
"TwoColumns": "Dva sloupce"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Karten",
"Content": "Inhalt",
"CreateCard": "Karte erstellen",
"AllowCreatingCards": "Erstellen von Karten erlauben",
"CreateMasterTag": "Typ erstellen",
"CreateTag": "Tag erstellen",
"MasterTag": "Typ",
@@ -78,6 +79,9 @@
"LockSection": "Sperren",
"UnLockSection": "Entsperren",
"SectionLocked": "Sektion {section} gesperrt",
"SectionUnlocked": "Sektion {section} entsperrt"
"SectionUnlocked": "Sektion {section} entsperrt",
"LayoutAuto": "Auto",
"SingleColumn": "Einspaltig",
"TwoColumns": "Zwei spaltig"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Cards",
"Content": "Content",
"CreateCard": "Create Card",
"AllowCreatingCards": "Allow creating cards",
"CreateMasterTag": "Create Type",
"CreateTag": "Create Tag",
"MasterTag": "Type",
@@ -78,6 +79,9 @@
"LockSection": "Lock section",
"UnLockSection": "Unlock section",
"SectionLocked": "Section {section} locked",
"SectionUnlocked": "Section {section} unlocked"
"SectionUnlocked": "Section {section} unlocked",
"LayoutAuto": "Auto",
"SingleColumn": "Single column",
"TwoColumns": "Two columns"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Tarjetas",
"Content": "Contenido",
"CreateCard": "Crear Tarjeta",
"AllowCreatingCards": "Permitir crear tarjetas",
"CreateMasterTag": "Crear Tipo",
"CreateTag": "Crear Etiqueta",
"MasterTag": "Tipo",
@@ -78,6 +79,9 @@
"LockSection": "Bloquear sección",
"UnLockSection": "Desbloquear sección",
"SectionLocked": "Sección {section} bloqueada",
"SectionUnlocked": "Sección {section} desbloqueada"
"SectionUnlocked": "Sección {section} desbloqueada",
"LayoutAuto": "Auto",
"SingleColumn": "Una columna",
"TwoColumns": "Dos columnas"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Cartes",
"Content": "Contenu",
"CreateCard": "Créer une carte",
"AllowCreatingCards": "Autoriser la création de cartes",
"CreateMasterTag": "Créer un type",
"CreateTag": "Créer une étiquette",
"MasterTag": "Type",
@@ -78,6 +79,9 @@
"LockSection": "Verrouiller la section",
"UnLockSection": "Déverrouiller la section",
"SectionLocked": "Section {section} verrouillée",
"SectionUnlocked": "Section {section} déverrouillée"
"SectionUnlocked": "Section {section} déverrouillée",
"LayoutAuto": "Auto",
"SingleColumn": "Une colonne",
"TwoColumns": "Deux colonnes"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Carte",
"Content": "Contenuto",
"CreateCard": "Crea Carta",
"AllowCreatingCards": "Consenti la creazione di carte",
"CreateMasterTag": "Crea Tipo",
"CreateTag": "Crea Tag",
"MasterTag": "Tipo",
@@ -78,6 +79,9 @@
"LockSection": "Blocca sezione",
"UnLockSection": "Sblocca sezione",
"SectionLocked": "Sezione {section} bloccata",
"SectionUnlocked": "Sezione {section} sbloccata"
"SectionUnlocked": "Sezione {section} sbloccata",
"LayoutAuto": "Auto",
"SingleColumn": "Una colonna",
"TwoColumns": "Due colonne"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "カード",
"Content": "コンテンツ",
"CreateCard": "カードを作成",
"AllowCreatingCards": "カードの作成を許可",
"CreateMasterTag": "タイプを作成",
"CreateTag": "タグを作成",
"MasterTag": "タイプ",
@@ -78,6 +79,9 @@
"LockSection": "セクションをロック",
"UnLockSection": "セクションをアンロック",
"SectionLocked": "セクション {section} ロック済み",
"SectionUnlocked": "セクション {section} アンロック済み"
"SectionUnlocked": "セクション {section} アンロック済み",
"LayoutAuto": "自動",
"SingleColumn": "1列",
"TwoColumns": "2列"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Cartões",
"Content": "Conteúdo",
"CreateCard": "Criar Cartão",
"AllowCreatingCards": "Permitir criar cartões",
"CreateMasterTag": "Criar Tipo",
"CreateTag": "Criar Tag",
"MasterTag": "Tipo",
@@ -78,6 +79,9 @@
"LockSection": "Bloquear seção",
"UnLockSection": "Desbloquear seção",
"SectionLocked": "Seção {section} bloqueada",
"SectionUnlocked": "Seção {section} desbloqueada"
"SectionUnlocked": "Seção {section} desbloqueada",
"LayoutAuto": "Automático",
"SingleColumn": "Uma coluna",
"TwoColumns": "Duas colunas"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Cartões",
"Content": "Conteúdo",
"CreateCard": "Criar Cartão",
"AllowCreatingCards": "Permitir criar cartões",
"CreateMasterTag": "Criar Tipo",
"CreateTag": "Criar Tag",
"MasterTag": "Tipo",
@@ -78,6 +79,9 @@
"LockSection": "Bloquear seção",
"UnLockSection": "Desbloquear seção",
"SectionLocked": "Seção {section} bloqueada",
"SectionUnlocked": "Seção {section} desbloqueada"
"SectionUnlocked": "Seção {section} desbloqueada",
"LayoutAuto": "Automático",
"SingleColumn": "Uma coluna",
"TwoColumns": "Duas colunas"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Карты",
"Content": "Содержание",
"CreateCard": "Создать карту",
"AllowCreatingCards": "Разрешить создание карт",
"CreateMasterTag": "Создать тип",
"CreateTag": "Создать тег",
"MasterTag": "Тип",
@@ -78,6 +79,9 @@
"LockSection": "Заблокировать секцию",
"UnLockSection": "Разблокировать секцию",
"SectionLocked": "Секция {section} заблокирована",
"SectionUnlocked": "Секция {section} разблокирована"
"SectionUnlocked": "Секция {section} разблокирована",
"LayoutAuto": "Авто",
"SingleColumn": "Один столбец",
"TwoColumns": "Два столбца"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "Kartlar",
"Content": "İçerik",
"CreateCard": "Kart Oluştur",
"AllowCreatingCards": "Kart oluşturmaya izin ver",
"CreateMasterTag": "Tip Oluştur",
"CreateTag": "Etiket Oluştur",
"MasterTag": "Tip",
@@ -78,6 +79,9 @@
"LockSection": "Bölümü kilitle",
"UnLockSection": "Bölümü kilitle",
"SectionLocked": "Bölüm {section} kilitli",
"SectionUnlocked": "Bölüm {section} kilidi açıldı"
"SectionUnlocked": "Bölüm {section} kilidi açıldı",
"LayoutAuto": "Otomatik",
"SingleColumn": "Tek sütun",
"TwoColumns": "İki sütun"
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
"Cards": "卡片",
"Content": "内容",
"CreateCard": "创建卡片",
"AllowCreatingCards": "允许创建卡片",
"CreateMasterTag": "创建类型",
"CreateTag": "创建标签",
"MasterTag": "类型",
@@ -78,6 +79,9 @@
"LockSection": "锁定部分",
"UnLockSection": "解锁部分",
"SectionLocked": "部分 {section} 已锁定",
"SectionUnlocked": "部分 {section} 已解锁"
"SectionUnlocked": "部分 {section} 已解锁",
"LayoutAuto": "自动",
"SingleColumn": "单列",
"TwoColumns": "两列"
}
}
@@ -13,13 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
import { Card } from '@hcengineering/card'
import { Card, MasterTag } from '@hcengineering/card'
import { Doc, Mixin } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Button, Grid, IconDownOutline, IconUpOutline, resizeObserver } from '@hcengineering/ui'
import card from '../plugin'
import MasterTagAttributes from './MasterTagAttributes.svelte'
import TagAttributes from './TagAttributes.svelte'
import { getClient } from '@hcengineering/presentation'
import { viewStore } from '../utils'
export let value: Card
export let readonly: boolean = false
@@ -30,9 +31,20 @@
const h = client.getHierarchy()
let width: number = 0
let layoutMode: 'auto' | '1' | '2' = 'auto'
let columns = 1
$: columns = width > 600 ? 2 : 1
viewStore.subscribe((views) => {
if (views[value._class] !== undefined) {
layoutMode = views[value._class] as any
} else {
const masterTag = h.getClass(value._class) as MasterTag
if (masterTag.singleColumn) {
layoutMode = '1'
}
}
})
$: columns = layoutMode === 'auto' ? (width > 600 ? 2 : 1) : parseInt(layoutMode)
const tagAttributes: TagAttributes[] = []
$: tagAttributes.length = mixins.length
@@ -88,6 +100,7 @@
<style lang="scss">
.btn {
margin-top: 1rem;
align-items: center;
}
.tag {
@@ -16,41 +16,46 @@
-->
<script lang="ts">
import { Card } from '@hcengineering/card'
import { NotificationContext } from '@hcengineering/communication-types'
import { Ref, WithLookup } from '@hcengineering/core'
import presence from '@hcengineering/presence'
import {
ComponentExtensions,
createNotificationContextsQuery,
createQuery,
getClient
} from '@hcengineering/presentation'
import {
Button,
Component,
createFocusManager,
deviceOptionsStore as deviceInfo,
DropdownIntlItem,
DropdownLabelsPopupIntl,
EditBox,
eventToHTMLElement,
FocusHandler,
getCurrentLocation,
IconDetailsFilled,
IconMaxWidth,
IconMoreH,
IPanelState,
navigate,
Panel,
IPanelState,
deviceOptionsStore as deviceInfo
showPopup
} from '@hcengineering/ui'
import presence from '@hcengineering/presence'
import {
createQuery,
createNotificationContextsQuery,
getClient,
ComponentExtensions
} from '@hcengineering/presentation'
import { canChangeDoc, showMenu } from '@hcengineering/view-resources'
import view from '@hcengineering/view'
import { NotificationContext } from '@hcengineering/communication-types'
import { canChangeDoc, showMenu } from '@hcengineering/view-resources'
import { permissionsStore } from '@hcengineering/contact-resources'
import { afterUpdate } from 'svelte'
import card from '../plugin'
import { openCardInSidebar, setViewMode, viewStore } from '../utils'
import CardIcon from './CardIcon.svelte'
import TagsEditor from './TagsEditor.svelte'
import CardVersionSelector from './CardVersionSelector.svelte'
import EditCardNewContent from './EditCardNewContent.svelte'
import ParentNamesPresenter from './ParentNamesPresenter.svelte'
import { openCardInSidebar } from '../utils'
import { afterUpdate } from 'svelte'
import { permissionsStore } from '@hcengineering/contact-resources'
import CardVersionSelector from './CardVersionSelector.svelte'
import TagsEditor from './TagsEditor.svelte'
export let _id: Ref<Card>
export let readonly: boolean = false
@@ -155,6 +160,35 @@
$: _readonly = (readonly || doc?.readonly || doc?.readonlyFields?.includes('title')) ?? false
$: updatePermissionForbidden = doc && !canChangeDoc(doc?._class, doc?.space, $permissionsStore)
function setLayout (mode: 'auto' | '1' | '2'): void {
if (doc === undefined) return
setViewMode(doc?._class, mode)
}
function showViewPopup (ev: MouseEvent): void {
if (doc === undefined) return
const items: DropdownIntlItem[] = [
{
id: 'auto',
label: card.string.LayoutAuto
},
{
id: '1',
label: card.string.SingleColumn
},
{
id: '2',
label: card.string.TwoColumns
}
]
const selected = $viewStore[doc._class] ?? 'auto'
showPopup(DropdownLabelsPopupIntl, { items, selected }, eventToHTMLElement(ev), async (result) => {
if (result != null && result !== '') {
setLayout(result)
}
})
}
</script>
<FocusHandler {manager} />
@@ -224,6 +258,14 @@
</svelte:fragment>
<svelte:fragment slot="utils">
<Button
kind="icon"
icon={IconMaxWidth}
iconProps={{ size: 'medium' }}
on:click={(ev) => {
showViewPopup(ev)
}}
/>
<Button
icon={IconDetailsFilled}
iconProps={{ size: 'medium' }}
@@ -76,7 +76,7 @@
{#if (isLocked && canUnlock) || (!isLocked && canLock)}
<div class="lock-btn">
<Button
icon={isLocked ? Unlock : Lock}
icon={isLocked ? Lock : Unlock}
kind={'link'}
size={'medium'}
showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }}
@@ -87,7 +87,7 @@
{#if (isLocked && canUnlock) || (!isLocked && canLock)}
<div class="lock-btn">
<Button
icon={isLocked ? Unlock : Lock}
icon={isLocked ? Lock : Unlock}
kind={'link'}
size={'medium'}
showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }}
@@ -199,6 +199,13 @@
on:change={enableVersioning}
/>
</div>
<div class="mx-2">
<ToggleWithLabel
label={card.string.SingleColumn}
on={masterTag.singleColumn}
on:change={(e) => attributeUpdated('singleColumn', e.detail)}
/>
</div>
{/if}
</div>
+4 -1
View File
@@ -167,6 +167,9 @@ export default mergeIds(cardId, card, {
CardUpdated: '' as IntlString,
CardCreated: '' as IntlString,
MyCards: '' as IntlString,
GotoMyCards: '' as IntlString
GotoMyCards: '' as IntlString,
SingleColumn: '' as IntlString,
TwoColumns: '' as IntlString,
LayoutAuto: '' as IntlString
}
})
+13
View File
@@ -73,6 +73,7 @@ import CardSearchItem from './components/CardSearchItem.svelte'
import CreateSpace from './components/navigator/CreateSpace.svelte'
import card from './plugin'
import { type NavigatorConfig } from './types'
import { writable } from 'svelte/store'
export async function deleteMasterTag (tag: MasterTag | undefined, onDelete?: () => void): Promise<void> {
if (tag !== undefined) {
@@ -780,3 +781,15 @@ export function canUnlockSection (space: Ref<Space>, store: PermissionsStore): b
if (allowed) return true
return !store.restrictedSpaces.has(space)
}
export const viewStore = writable<Record<Ref<MasterTag>, string>>(
JSON.parse(localStorage.getItem('card.layout') ?? '{}')
)
export function setViewMode (type: Ref<MasterTag>, mode: string): void {
viewStore.update((views) => {
views[type] = mode
localStorage.setItem('card.layout', JSON.stringify(views))
return views
})
}
+5 -1
View File
@@ -39,6 +39,7 @@ export interface MasterTag extends Class<Card> {
background?: number
removed?: boolean
roles?: CollectionSize<Role>
singleColumn?: boolean
}
export interface Tag extends MasterTag, Mixin<Card> {}
@@ -205,6 +206,7 @@ const cardPlugin = plugin(cardId, {
AllCards: '' as IntlString,
Favorites: '' as IntlString,
CreateCard: '' as IntlString,
AllowCreatingCards: '' as IntlString,
Version: '' as IntlString,
Versions: '' as IntlString,
LockSection: '' as IntlString,
@@ -222,7 +224,9 @@ const cardPlugin = plugin(cardId, {
CommunicationMessages: '' as Ref<CardSection>
},
ids: {
CardWidget: '' as Ref<Doc>
CardWidget: '' as Ref<Doc>,
GuestCardClassPermission: '' as Ref<Doc>,
ModulePermissionGroup: '' as Ref<Doc>
},
component: {
LabelsPresenter: '' as AnyComponent,
@@ -40,6 +40,8 @@
export let draggedItem: Ref<DocumentMeta> | undefined = undefined
export let draggedOver: Ref<DocumentMeta> | undefined = undefined
export let spaceIconFill: string | undefined = undefined
import DropArea from './DropArea.svelte'
const removeStates = [DocumentState.Obsolete, DocumentState.Deleted]
@@ -72,7 +74,7 @@
_id={docid}
icon={isFolder ? documents.icon.Folder : documents.icon.Document}
iconProps={{
fill: isRemoved ? 'var(--dangerous-bg-color)' : 'currentColor'
fill: isRemoved ? 'var(--dangerous-bg-color)' : (spaceIconFill ?? 'currentColor')
}}
{title}
selected={selected === docid || selected === prjdoc._id}
@@ -108,6 +110,7 @@
{collapsedPrefix}
{getMoreActions}
level={level + 1}
{spaceIconFill}
{onDragStart}
{onDragOver}
{onDragEnd}
@@ -39,13 +39,15 @@
}
$: root = tree.childrenOf(documents.ids.NoParent)
$: spaceIconFill = getPlatformColorForTextDef(space.name, $themeStore.dark).icon
</script>
<TreeNode
_id={space?._id}
folderIcon
iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
fill: spaceIconFill
}}
title={space.name}
highlighted={selected !== undefined}
@@ -57,5 +59,5 @@
dispatch('selected', space)
}}
>
<DocHierarchyLevel documentIds={root} {tree} {selected} {collapsedPrefix} on:selected />
<DocHierarchyLevel documentIds={root} {tree} {selected} {collapsedPrefix} {spaceIconFill} on:selected />
</TreeNode>
@@ -78,6 +78,8 @@
$: selected = getDocumentIdFromFragment(currentFragment ?? '')
$: spaceIconFill = getPlatformColorForTextDef(space.name, $themeStore.dark).icon
let project: Ref<Project> = documents.ids.NoProject
$: void selectProject(space)
@@ -315,7 +317,7 @@
_id={space?._id}
folderIcon
iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
fill: spaceIconFill
}}
title={space.name}
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
@@ -358,6 +360,7 @@
{tree}
documentIds={root}
{selected}
{spaceIconFill}
getMoreActions={getDocumentActions}
on:selected={(e) => {
handleDocumentSelected(e.detail)
@@ -382,7 +385,7 @@
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
fill: spaceIconFill
}}
title={getDocumentName(doc)}
actions={() => getDocumentActions(doc)}
+6
View File
@@ -9,6 +9,12 @@
<path d="M4.5 11C3.11929 11 2 12.1193 2 13.5C2 13.7761 1.77614 14 1.5 14C1.22386 14 1 13.7761 1 13.5C1 11.567 2.567 10 4.5 10H6.5C8.433 10 10 11.567 10 13.5C10 13.7761 9.77614 14 9.5 14C9.22386 14 9 13.7761 9 13.5C9 12.1193 7.88071 11 6.5 11H4.5Z" />
<path d="M10.5 2.99988C10.2238 2.99988 9.99995 3.22374 9.99995 3.49988C9.99995 3.77602 10.2238 3.99988 10.5 3.99988C10.7846 3.99988 11.0661 4.06066 11.3254 4.17815C11.5847 4.29564 11.8159 4.46714 12.0036 4.68119C12.1913 4.89523 12.3312 5.14688 12.4138 5.41931C12.4965 5.69174 12.52 5.97868 12.4828 6.26093C12.4457 6.54318 12.3487 6.81425 12.1984 7.05601C12.048 7.29777 11.8478 7.50465 11.6111 7.66282C11.3744 7.82098 11.1066 7.92679 10.8257 7.97316C10.5449 8.01954 10.2573 8.00541 9.98232 7.93173C9.71558 7.86026 9.44141 8.01855 9.36994 8.28528C9.29847 8.55202 9.45677 8.82618 9.7235 8.89766C10.136 9.00818 10.5673 9.02937 10.9886 8.95981C11.41 8.89025 11.8116 8.73153 12.1667 8.49429C12.5217 8.25704 12.8221 7.94672 13.0476 7.58408C13.2731 7.22144 13.4186 6.81484 13.4743 6.39146C13.53 5.96807 13.4947 5.53767 13.3708 5.12902C13.2468 4.72038 13.037 4.3429 12.7555 4.02184C12.4739 3.70078 12.127 3.44353 11.7381 3.26729C11.3491 3.09105 10.927 2.99988 10.5 2.99988Z" />
</symbol>
<symbol id="members" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.7,5.7c-1.4,0-2.7,0.5-3.5,1.5c-0.9,0.9-1.3,2.3-1.2,3.7c0.2,2.7,2.3,5.1,4.8,5.1c2.5,0,4.6-2.3,4.8-5.1C25.7,8,23.5,5.7,20.7,5.7z M18.5,8.5C18,9,17.7,9.8,17.7,10.8c0.1,2,1.6,3.3,2.9,3.3c1.3,0,2.8-1.3,2.9-3.3c0.1-1.9-1.1-3.2-2.9-3.2C19.8,7.6,19,7.9,18.5,8.5z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.4,24.1c1.1-4.2,5.2-6.3,9.3-6.3c4,0,8.2,2,9.3,6.3c0.2,0.9-0.4,2.1-1.6,2.1H13C11.7,26.3,11.2,25.1,11.4,24.1z M13.3,24.4h14.8c-0.9-3-3.9-4.7-7.4-4.7C17.2,19.7,14.1,21.4,13.3,24.4z" />
<path d="M9.6,16.2c-2.1,0-3.9-1.9-4-4.3c-0.1-1.2,0.3-2.3,1-3.1c0.8-0.8,1.8-1.2,3-1.2c1.2,0,2.2,0.4,3,1.3c0.8,0.8,1.1,1.9,1.1,3.1C13.5,14.3,11.7,16.2,9.6,16.2z M9.6,9.5C9,9.5,8.4,9.7,8,10.1c-0.4,0.4-0.6,1-0.5,1.7c0.1,1.4,1.1,2.5,2.2,2.5s2.1-1.2,2.2-2.5c0-0.7-0.2-1.3-0.6-1.7C10.9,9.7,10.3,9.5,9.6,9.5z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M2,22.5c0.9-3.5,4.3-5.2,7.6-5.2c1.3,0,2.6,0.2,3.8,0.8c0.5,0.2,0.7,0.8,0.5,1.2c-0.2,0.5-0.8,0.7-1.2,0.5c-0.9-0.4-1.9-0.6-3.1-0.6c-2.6,0-4.9,1.2-5.7,3.4H10c0.5,0,0.9,0.4,0.9,0.9s-0.4,0.9-0.9,0.9H3.5C2.4,24.4,1.8,23.3,2,22.5L2,22.5z" />
</symbol>
<symbol id="password" viewBox="0 0 16 16">
<path d="M11 6C11.5523 6 12 5.55228 12 5C12 4.44772 11.5523 4 11 4C10.4477 4 10 4.44772 10 5C10 5.55228 10.4477 6 11 6Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 1C8.01472 1 6 3.01472 6 5.5C6 5.89536 6.05111 6.27942 6.14726 6.64563L1.29289 11.5C1.10536 11.6875 1 11.9419 1 12.2071V14.5C1 14.7761 1.22386 15 1.5 15H3.79289C4.05811 15 4.31246 14.8946 4.5 14.7071L9.35437 9.85274C9.72058 9.94889 10.1046 10 10.5 10C12.9853 10 15 7.98528 15 5.5C15 3.01472 12.9853 1 10.5 1ZM7 5.5C7 3.567 8.567 2 10.5 2C12.433 2 14 3.567 14 5.5C14 7.433 12.433 9 10.5 9C10.1048 9 9.72588 8.93469 9.37284 8.81468C9.19252 8.75339 8.99304 8.79986 8.85837 8.93453L8 9.79289L7.85355 9.64645C7.65829 9.45118 7.34171 9.45118 7.14645 9.64645C6.95118 9.84171 6.95118 10.1583 7.14645 10.3536L7.29289 10.5L6.5 11.2929L5.85355 10.6464C5.65829 10.4512 5.34171 10.4512 5.14645 10.6464C4.95118 10.8417 4.95118 11.1583 5.14645 11.3536L5.79289 12L5 12.7929L4.35355 12.1464C4.15829 11.9512 3.84171 11.9512 3.64645 12.1464C3.45118 12.3417 3.45118 12.6583 3.64645 12.8536L4.29289 13.5L3.79289 14H2V12.2071L7.06547 7.14163C7.20014 7.00696 7.24661 6.80748 7.18532 6.62716C7.06531 6.27412 7 5.8952 7 5.5Z" />

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+3
View File
@@ -212,6 +212,9 @@
"OfficeDefaultSettings": "Výchozí nastavení pro jednací místnosti",
"DefaultStartWithTranscription": "Povolit přepis v nových kancelářích",
"DefaultStartWithRecording": "Povolit záznam v nových kancelářích",
"GuestPermissionsSettings": "Oprávnění hostů",
"GuestPermissionsModulePermissions": "Oprávnění modulů",
"GuestPermissionsModulePermissionsHint": "Vyberte, které moduly mohou hosté používat, a níže upravte oprávnění pro jednotlivé aplikace.",
"ImportDocumentPermission": "Importovat dokumenty",
"ImportDocumentDescription": "Umožňuje uživatelům importovat dokumenty do pracovního prostoru",
"SelectUsers": "Vybrat uživatele",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Standardeinstellungen für Besprechungsräume",
"DefaultStartWithTranscription": "Transkription in neuen Büroräumen aktivieren",
"DefaultStartWithRecording": "Aufnahme in neuen Büroräumen aktivieren",
"GuestPermissionsSettings": "Gastberechtigungen",
"GuestPermissionsModulePermissions": "Modulberechtigungen",
"GuestPermissionsModulePermissionsHint": "Wählen Sie, welche Module Gäste nutzen dürfen, und passen Sie unten die Berechtigungen für jede App an.",
"ImportDocumentPermission": "Dokumente importieren",
"ImportDocumentDescription": "Gewährt Benutzern die Möglichkeit, Dokumente in den Arbeitsbereich zu importieren",
"SelectUsers": "Benutzer auswählen",
+3
View File
@@ -215,6 +215,9 @@
"OfficeDefaultSettings": "Default settings for meeting rooms",
"DefaultStartWithTranscription": "Enable transcription in new office rooms",
"DefaultStartWithRecording": "Enable recording in new office rooms",
"GuestPermissionsSettings": "Guest permissions",
"GuestPermissionsModulePermissions": "Module permissions",
"GuestPermissionsModulePermissionsHint": "Choose which modules guests can use, then adjust permissions for each app below.",
"ImportDocumentPermission": "Import documents",
"ImportDocumentDescription": "Grants users ability to import documents into the workspace",
"SelectUsers": "Select users",
+3
View File
@@ -205,6 +205,9 @@
"OfficeDefaultSettings": "Configuración predeterminada para salas de reuniones",
"DefaultStartWithTranscription": "Habilitar transcripción en nuevas oficinas",
"DefaultStartWithRecording": "Habilitar grabación en nuevas oficinas",
"GuestPermissionsSettings": "Permisos de invitados",
"GuestPermissionsModulePermissions": "Permisos de módulos",
"GuestPermissionsModulePermissionsHint": "Elija qué módulos pueden usar los invitados y luego ajuste los permisos de cada aplicación a continuación.",
"ImportDocumentPermission": "Importar documentos",
"ImportDocumentDescription": "Otorga a los usuarios la capacidad de importar documentos al espacio de trabajo",
"SelectUsers": "Seleccionar usuarios",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Paramètres par défaut pour les salles de réunion",
"DefaultStartWithTranscription": "Activer la transcription dans les nouveaux bureaux",
"DefaultStartWithRecording": "Activer l'enregistrement dans les nouveaux bureaux",
"GuestPermissionsSettings": "Permissions des invités",
"GuestPermissionsModulePermissions": "Permissions des modules",
"GuestPermissionsModulePermissionsHint": "Choisissez les modules accessibles aux invités, puis ajustez les permissions pour chaque application ci-dessous.",
"ImportDocumentPermission": "Importer des documents",
"ImportDocumentDescription": "Accorde aux utilisateurs la possibilité d'importer des documents dans l'espace de travail",
"SelectUsers": "Sélectionner des utilisateurs",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Impostazioni predefinite per le sale riunioni",
"DefaultStartWithTranscription": "Abilita trascrizione nelle nuove stanze dell'ufficio",
"DefaultStartWithRecording": "Abilita registrazione nelle nuove stanze dell'ufficio",
"GuestPermissionsSettings": "Permessi ospite",
"GuestPermissionsModulePermissions": "Permessi dei moduli",
"GuestPermissionsModulePermissionsHint": "Scegli quali moduli possono usare gli ospiti, poi regola i permessi per ogni app qui sotto.",
"ImportDocumentPermission": "Importa documenti",
"ImportDocumentDescription": "Concede agli utenti la possibilità di importare documenti nell'area di lavoro",
"SelectUsers": "Seleziona utenti",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "会議室のデフォルト設定",
"DefaultStartWithTranscription": "新しいオフィスルームで文字起こしを有効にする",
"DefaultStartWithRecording": "新しいオフィスルームで録画を有効にする",
"GuestPermissionsSettings": "ゲストの権限",
"GuestPermissionsModulePermissions": "モジュールの権限",
"GuestPermissionsModulePermissionsHint": "ゲストが利用できるモジュールを選び、その下で各アプリの権限を調整します。",
"ImportDocumentPermission": "ドキュメントをインポート",
"ImportDocumentDescription": "ユーザーにワークスペースにドキュメントをインポートする機能を付与します",
"SelectUsers": "ユーザーを選択",
+3
View File
@@ -205,6 +205,9 @@
"OfficeDefaultSettings": "Configurações padrão para salas de reunião",
"DefaultStartWithTranscription": "Habilitar transcrição em novos escritórios",
"DefaultStartWithRecording": "Habilitar gravação em novos escritórios",
"GuestPermissionsSettings": "Permissões de convidados",
"GuestPermissionsModulePermissions": "Permissões de módulos",
"GuestPermissionsModulePermissionsHint": "Escolha quais módulos os convidados podem usar e, em seguida, ajuste as permissões de cada aplicativo abaixo.",
"ImportDocumentPermission": "Importar documentos",
"ImportDocumentDescription": "Concede aos usuários a capacidade de importar documentos para o espaço de trabalho",
"SelectUsers": "Selecionar usuários",
+3
View File
@@ -205,6 +205,9 @@
"OfficeDefaultSettings": "Configurações padrão para salas de reunião",
"DefaultStartWithTranscription": "Habilitar transcrição em novos escritórios",
"DefaultStartWithRecording": "Habilitar gravação em novos escritórios",
"GuestPermissionsSettings": "Permissões de convidados",
"GuestPermissionsModulePermissions": "Permissões de módulos",
"GuestPermissionsModulePermissionsHint": "Escolha quais módulos os convidados podem usar e, em seguida, ajuste as permissões de cada aplicação abaixo.",
"ImportDocumentPermission": "Importar documentos",
"ImportDocumentDescription": "Concede aos usuários a capacidade de importar documentos para o espaço de trabalho",
"SelectUsers": "Selecionar usuários",
+3
View File
@@ -215,6 +215,9 @@
"OfficeDefaultSettings": "Настройки по умолчанию для переговорных",
"DefaultStartWithTranscription": "Включить транскрипцию в новых комнатах",
"DefaultStartWithRecording": "Включить запись в новых комнатах",
"GuestPermissionsSettings": "Права гостей",
"GuestPermissionsModulePermissions": "Права модулей",
"GuestPermissionsModulePermissionsHint": "Выберите, какие модули доступны гостям, затем настройте права для каждого приложения ниже.",
"ImportDocumentPermission": "Импорт документов",
"ImportDocumentDescription": "Предоставляет пользователям возможность импортировать документы в рабочее пространство",
"SelectUsers": "Выбрать пользователей",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Toplantı odaları için varsayılan ayarlar",
"DefaultStartWithTranscription": "Yeni ofis odalarında transkripsiyonu etkinleştir",
"DefaultStartWithRecording": "Yeni ofis odalarında kaydı etkinleştir",
"GuestPermissionsSettings": "Misafir izinleri",
"GuestPermissionsModulePermissions": "Modül izinleri",
"GuestPermissionsModulePermissionsHint": "Misafirlerin hangi modülleri kullanabileceğini seçin, ardından her uygulama için izinleri aşağıdan ayarlayın.",
"ImportDocumentPermission": "Belgeleri içe aktar",
"ImportDocumentDescription": "Kullanıcılara çalışma alanına belge içe aktarma yeteneği verir",
"SelectUsers": "Kullanıcıları seç",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "会议室的默认设置",
"DefaultStartWithTranscription": "在新办公室启用转录",
"DefaultStartWithRecording": "在新办公室启用录制",
"GuestPermissionsSettings": "访客权限",
"GuestPermissionsModulePermissions": "模块权限",
"GuestPermissionsModulePermissionsHint": "选择访客可以使用哪些模块,然后在下方调整每个应用的权限。",
"ImportDocumentPermission": "导入文档",
"ImportDocumentDescription": "授予用户将文档导入工作区的权限",
"SelectUsers": "选择用户",
+1
View File
@@ -20,6 +20,7 @@ const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(setting.icon, {
AccountSettings: `${icons}#accountSettings`,
Owners: `${icons}#owners`,
Members: `${icons}#members`,
Password: `${icons}#password`,
Setting: `${icons}#settings`,
Integrations: `${icons}#integration`,
@@ -0,0 +1,376 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import core, { ModulePermissionGroup, type Doc, type Permission, type Ref } from '@hcengineering/core'
import { getEmbeddedLabel, getMetadata, type IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import workbench, { type Application } from '@hcengineering/workbench'
import { Breadcrumb, Header, Icon, Label, Loading, Scroller, Toggle } from '@hcengineering/ui'
import setting from '@hcengineering/setting'
let loadingSettings = true
let loadingPermissions = true
let workspaceAppsReady = false
let moduleGroups: ModulePermissionGroup[] = []
let permissionsMap: Map<Ref<Permission>, Permission> = new Map<Ref<Permission>, Permission>()
let hiddenApplicationIds: Array<Ref<Application>> = []
const excludedApplicationIds = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const client = getClient()
const moduleGroupsQuery = createQuery()
const permissionsQuery = createQuery()
const hiddenAppsQuery = createQuery()
$: moduleGroupsQuery.query(core.class.ModulePermissionGroup, {}, (res) => {
moduleGroups = res as unknown as ModulePermissionGroup[]
loadingSettings = false
})
$: permissionsQuery.query(core.class.Permission, {}, (res) => {
permissionsMap = new Map((res as Permission[]).map((permission) => [permission._id, permission]))
loadingPermissions = false
})
$: hiddenAppsQuery.query(workbench.class.HiddenApplication, { space: core.space.Workspace }, (res) => {
hiddenApplicationIds = res.map((r) => r.attachedTo)
workspaceAppsReady = true
})
/** Same notion of “available in this workspace” as the app switcher: model apps minus hidden/excluded. */
$: workspaceApplications = client
.getModel()
.findAllSync<Application>(workbench.class.Application, {
hidden: false,
_id: { $nin: excludedApplicationIds }
})
.filter((app) => !hiddenApplicationIds.includes(app._id))
$: applicationsMap = new Map<Ref<Doc>, Application>(
workspaceApplications.map((application) => [application._id as Ref<Doc>, application])
)
$: loading = loadingSettings || loadingPermissions || !workspaceAppsReady
/** Ignore permission groups for applications not enabled in this workspace. */
$: visibleModuleGroups = moduleGroups.filter((group) => applicationsMap.has(group.application))
$: sortedVisibleModuleGroups = [...visibleModuleGroups].sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
function getApplicationLabel (applicationId: Ref<Doc>): IntlString {
return applicationsMap.get(applicationId)?.label ?? getEmbeddedLabel(applicationId)
}
function getApplication (applicationId: Ref<Doc>): Application | undefined {
return applicationsMap.get(applicationId)
}
function getDisabledPermissions (group: ModulePermissionGroup): Set<Ref<Permission>> {
return new Set(group.disabledPermissions ?? [])
}
function isPermissionActive (group: ModulePermissionGroup, permissionId: Ref<Permission>): boolean {
return !getDisabledPermissions(group).has(permissionId)
}
async function togglePermission (
group: ModulePermissionGroup,
permissionId: Ref<Permission>,
enabled: boolean
): Promise<void> {
if (!isModuleEnabled(group)) return
const disabled = getDisabledPermissions(group)
if (enabled) {
disabled.delete(permissionId)
} else {
disabled.add(permissionId)
}
await client.updateDoc(core.class.ModulePermissionGroup, core.space.Model, group._id, {
disabledPermissions: Array.from(disabled)
} as any)
}
async function toggleModule (group: ModulePermissionGroup, enabled: boolean): Promise<void> {
await client.updateDoc(core.class.ModulePermissionGroup, core.space.Model, group._id, {
enabled
} as any)
}
function isModuleEnabled (group: ModulePermissionGroup): boolean {
return group.enabled ?? true
}
function getPermissionLabel (permissionId: Ref<Permission>): IntlString {
return permissionsMap.get(permissionId)?.label ?? getEmbeddedLabel(permissionId)
}
function onAccessToggle (group: ModulePermissionGroup, ev: Event): void {
const e = ev as CustomEvent<boolean>
void toggleModule(group, e.detail)
}
function onPermissionToggle (group: ModulePermissionGroup, permissionId: Ref<Permission>, ev: Event): void {
const e = ev as CustomEvent<boolean>
void togglePermission(group, permissionId, e.detail)
}
function handleAccessToggle (group: ModulePermissionGroup): (ev: Event) => void {
return (ev: Event) => {
onAccessToggle(group, ev)
}
}
function handlePermissionToggle (group: ModulePermissionGroup, permissionId: Ref<Permission>): (ev: Event) => void {
return (ev: Event) => {
onPermissionToggle(group, permissionId, ev)
}
}
</script>
<div class="hulyComponent">
<Header adaptive={'disabled'}>
<Breadcrumb label={setting.string.GuestPermissionsSettings} size={'large'} isCurrent />
</Header>
<div class="hulyComponent-content__column content">
{#if loading}
<div class="w-full h-full flex-col-center justify-center">
<Loading />
</div>
{:else}
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content guestPermissionsRoot flex-col">
<section class="section">
<div class="sectionHeader">
<div class="sectionTitle">
<Label label={setting.string.GuestPermissionsModulePermissions} />
</div>
<div class="sectionHint">
<Label label={setting.string.GuestPermissionsModulePermissionsHint} />
</div>
</div>
<div class="cardStack">
{#each sortedVisibleModuleGroups as group}
{@const app = getApplication(group.application)}
{@const moduleOn = isModuleEnabled(group)}
{@const permissionCount = (group.permissions ?? []).length}
<div class="permissionModuleCard" class:permissionModuleCard-off={!moduleOn}>
<div
class="permissionModuleCard-header"
class:permissionModuleCard-headerOnly={permissionCount === 0}
>
<div class="permissionModuleCard-headerMain">
{#if app}
<div class="appIcon appIcon-sm">
<Icon icon={app.icon} size={'small'} />
</div>
{:else}
<div class="appIcon appIcon-sm appIcon-placeholder" />
{/if}
<div class="permissionModuleCard-titles">
<div class="permissionModuleCard-name">
<Label label={getApplicationLabel(group.application)} />
</div>
</div>
</div>
<div class="permissionModuleCard-toggleCell">
<Toggle on={moduleOn} on:change={handleAccessToggle(group)} />
</div>
</div>
{#if permissionCount > 0}
<div class="permissionRows">
{#each group.permissions ?? [] as permissionId}
<div class="permissionRow">
<div class="permissionRow-label">
<Label label={getPermissionLabel(permissionId)} />
</div>
<div class="permissionRow-toggleCell">
<Toggle
disabled={!moduleOn}
on={isPermissionActive(group, permissionId)}
on:change={handlePermissionToggle(group, permissionId)}
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#if visibleModuleGroups.length === 0}
<div class="emptyState emptyState-block"></div>
{/if}
</div>
</section>
</div>
</Scroller>
{/if}
</div>
</div>
<style lang="scss">
.guestPermissionsRoot {
max-width: 40rem;
width: 100%;
margin: 0 auto;
gap: 2rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sectionTitle {
font-weight: 500;
font-size: 1rem;
color: var(--theme-content-color);
}
.sectionHint {
font-size: 0.8rem;
color: var(--theme-halfcontent-color);
}
/* Matches packages/ui Toggle width for a single aligned column */
$toggleTrackWidth: 2.25rem;
.cardStack {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 0.5rem;
}
.appIcon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--small-focus-BorderRadius);
background-color: var(--theme-button-default);
color: var(--theme-caption-color);
}
.appIcon-sm {
width: 2rem;
height: 2rem;
}
.appIcon-placeholder {
opacity: 0.35;
}
.permissionModuleCard {
display: flex;
flex-direction: column;
border-radius: var(--small-focus-BorderRadius);
border: 1px solid var(--theme-navpanel-divider);
overflow: hidden;
background-color: var(--theme-panel-color);
box-shadow: var(--theme-popup-shadow);
}
.permissionModuleCard-off .permissionRows {
opacity: 0.55;
}
.permissionModuleCard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) #{$toggleTrackWidth};
align-items: center;
column-gap: 0.75rem;
padding: 0.75rem 1rem;
background-color: var(--theme-comp-header-color);
border-bottom: 1px solid var(--theme-divider-color);
&.permissionModuleCard-headerOnly {
border-bottom: none;
}
}
.permissionModuleCard-headerMain {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.permissionModuleCard-toggleCell,
.permissionRow-toggleCell {
display: flex;
justify-content: center;
align-items: center;
width: $toggleTrackWidth;
justify-self: end;
}
.permissionModuleCard-name {
font-weight: 500;
font-size: 0.9375rem;
color: var(--theme-content-color);
}
.permissionModuleCard-meta {
font-size: 0.75rem;
color: var(--theme-halfcontent-color);
}
.permissionRows {
display: flex;
flex-direction: column;
padding: 0.25rem 1rem 0.75rem;
background-color: var(--theme-panel-color);
}
.permissionRow {
display: grid;
grid-template-columns: minmax(0, 1fr) #{$toggleTrackWidth};
align-items: center;
column-gap: 0.75rem;
padding: 0.625rem 0 0.625rem 0.25rem;
min-height: 2.5rem;
}
.permissionRow:not(:first-child) {
border-top: 1px solid var(--theme-navpanel-divider);
}
.permissionRow-label {
min-width: 0;
color: var(--theme-content-color);
}
.emptyState {
font-size: 0.875rem;
color: var(--theme-halfcontent-color);
padding: 0.25rem 0;
}
.emptyState-block {
padding: 0.5rem 0;
}
</style>
+2
View File
@@ -72,6 +72,7 @@ import EditRelation from './components/EditRelation.svelte'
import AddSocialId from './components/socialIds/AddSocialId.svelte'
import AddEmailSocialId from './components/socialIds/AddEmailSocialId.svelte'
import Mailboxes from './components/Mailboxes.svelte'
import GuestPermissionsSettings from './components/GuestPermissionsSettings.svelte'
import OfficeSettings from './components/OfficeSettings.svelte'
import BaseIntegrationState from './components/integrations/BaseIntegrationState.svelte'
import IntegrationStateRow from './components/integrations/IntegrationStateRow.svelte'
@@ -164,6 +165,7 @@ export default async (): Promise<Resources> => ({
CreateRelation,
EditRelation,
Mailboxes,
GuestPermissionsSettings,
OfficeSettings,
AddSocialId,
AddEmailSocialId,
+2 -1
View File
@@ -30,7 +30,8 @@ export default mergeIds(settingId, setting, {
ManageSpaceTypesTools: '' as AnyComponent,
ManageSpaceTypeContent: '' as AnyComponent,
Spaces: '' as AnyComponent,
AddSocialId: '' as AnyComponent
AddSocialId: '' as AnyComponent,
GuestPermissionsSettings: '' as AnyComponent
},
string: {
IntegrationDisabled: '' as IntlString,
+4
View File
@@ -311,6 +311,9 @@ export default plugin(settingId, {
OfficeDefaultSettings: '' as IntlString,
DefaultStartWithTranscription: '' as IntlString,
DefaultStartWithRecording: '' as IntlString,
GuestPermissionsSettings: '' as IntlString,
GuestPermissionsModulePermissions: '' as IntlString,
GuestPermissionsModulePermissionsHint: '' as IntlString,
MailboxErrorInvalidName: '' as IntlString,
MailboxErrorDomainNotFound: '' as IntlString,
MailboxErrorNameRulesViolated: '' as IntlString,
@@ -352,6 +355,7 @@ export default plugin(settingId, {
icon: {
AccountSettings: '' as Asset,
Owners: '' as Asset,
Members: '' as Asset,
Password: '' as Asset,
Setting: '' as Asset,
Integrations: '' as Asset,
@@ -44,6 +44,7 @@ const mermaidMetaTxField = 'mermaid-meta-tx'
interface TxMetaContainer {
nodePatch?: NodePatchSpec
nodePatches?: NodePatchSpec[]
renderResult?: MermaidRenderResult
updateDecorations?: boolean
}
@@ -122,7 +123,7 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
addProseMirrorPlugins () {
const parent = (this.parent?.() ?? []).filter((p) => p.props.handlePaste === undefined)
return [...parent, MermaidDecorator(this.options)]
return [...parent, MermaidCodeBlockNormalizer(), MermaidDecorator(this.options)]
},
addNodeView () {
@@ -358,6 +359,75 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
}
})
/**
* Normalizes pasted/imported content so that Mermaid blocks render.
*
* There are multiple ways Mermaid content can enter the editor (markdown paste, html paste,
* programmatic inserts). Some of those paths produce a regular `codeBlock` with
* `attrs.language === 'mermaid'` instead of a dedicated `mermaid` node.
*
* Our rendering pipeline only targets `mermaid` nodes, so we convert such `codeBlock`s
* into `mermaid` nodes on any doc-changing transaction.
*/
function MermaidCodeBlockNormalizer (): Plugin {
return new Plugin({
key: new PluginKey('mermaid-codeblock-normalizer'),
appendTransaction (transactions, oldState, newState) {
if (!transactions.some((tr) => tr.docChanged)) return
const { schema } = newState
const mermaidType = schema.nodes[MermaidExtension.name]
const codeBlockType = schema.nodes.codeBlock
if (mermaidType == null || codeBlockType == null) return
const targets: Array<{ pos: number, node: ProseMirrorNode }> = []
newState.doc.descendants((node, pos) => {
if (node.type !== codeBlockType) return
if ((node.attrs as any)?.language !== 'mermaid') return
targets.push({ pos, node })
})
if (targets.length === 0) return
// Replace from end to start to keep positions stable.
const tr = newState.tr
const selectionPos = newState.selection.from
const nodePatches: NodePatchSpec[] = []
let shouldMoveSelection = false
let selectionTargetPos = 0
for (let i = targets.length - 1; i >= 0; i--) {
const { pos, node } = targets[i]
const attrs = { ...(node.attrs ?? {}), language: 'mermaid' }
tr.replaceRangeWith(pos, pos + node.nodeSize, mermaidType.create(attrs, node.content, node.marks))
// If the user selection is inside the normalized block, keep it editable (unfolded)
// and keep the cursor inside the new node.
if (selectionPos >= pos && selectionPos <= pos + node.nodeSize) {
nodePatches.push({ pos, folded: false, selected: false })
shouldMoveSelection = true
selectionTargetPos = pos
}
}
if (nodePatches.length > 0) {
setTxMeta(tr, { nodePatches })
}
if (shouldMoveSelection) {
// Place the cursor into the code content of the mermaid node.
// Node content starts at `pos + 1`.
const nextSelection =
TextSelection.findFrom(tr.doc.resolve(selectionTargetPos + 1), 1) ??
TextSelection.create(tr.doc, selectionTargetPos + 1)
tr.setSelection(nextSelection)
}
return tr
}
})
}
interface MermaidPluginState {
decorationSet: DecorationSet
decorationCache: Map<number | string, NodeDecorationState>
@@ -554,6 +624,11 @@ function buildState (
const lastDecorationSet = tr !== undefined ? prev.decorationSet.map(tr.mapping, tr.doc) : prev.decorationSet
const nodeStatePatch = getTxMeta(tr)?.nodePatch
const nodeStatePatches = getTxMeta(tr)?.nodePatches
const nodeStatePatchByPos =
nodeStatePatches !== undefined && nodeStatePatches.length > 0
? new Map<number, NodePatchSpec>(nodeStatePatches.map((p) => [p.pos, p]))
: undefined
let mIndex = 0
doc.descendants((node, pos, parent, index) => {
@@ -584,9 +659,12 @@ function buildState (
textContent: node.textContent
}
if (nodeStatePatch !== undefined && pos === nodeStatePatch.pos) {
newState.folded = nodeStatePatch.folded
newState.selected = nodeStatePatch.selected
const patch =
nodeStatePatchByPos?.get(pos) ??
(nodeStatePatch !== undefined && pos === nodeStatePatch.pos ? nodeStatePatch : undefined)
if (patch !== undefined) {
newState.folded = patch.folded
newState.selected = patch.selected
}
if (yid !== undefined) decorationCache.set(yid, newState)
@@ -0,0 +1,245 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Schema } from '@tiptap/pm/model'
import { EditorState, NodeSelection, TextSelection } from '@tiptap/pm/state'
import { PasteTextAsMarkdownPlugin } from '../smartPaste'
jest.mock('@hcengineering/text', () => ({
__esModule: true,
MarkupNodeType: {
doc: 'doc',
text: 'text',
paragraph: 'paragraph',
heading: 'heading',
code_block: 'codeBlock',
bullet_list: 'bulletList',
list_item: 'listItem',
table: 'table',
todoList: 'todoList',
ordered_list: 'orderedList',
reference: 'reference',
image: 'image',
mermaid: 'mermaid'
},
MarkupMarkType: {
bold: 'bold',
em: 'em',
code: 'code',
link: 'link'
}
}))
jest.mock('@hcengineering/text-markdown', () => ({
__esModule: true,
markdownToMarkup: (markdown: string) => ({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: markdown.trim().length > 0 ? markdown.trim() : 'title' }]
}
]
})
}))
jest.mock('../../codeSnippets/codeblock', () => ({
__esModule: true,
CodeBlockHighlighExtension: { name: 'codeBlock' }
}))
function makeSchema (): Schema {
return new Schema({
nodes: {
doc: { content: 'block+' },
text: { group: 'inline' },
heading: {
group: 'block',
content: 'inline*',
attrs: { level: { default: 1 } },
toDOM: (node) => ['h' + node.attrs.level, 0],
parseDOM: [
{ tag: 'h1', attrs: { level: 1 } },
{ tag: 'h2', attrs: { level: 2 } },
{ tag: 'h3', attrs: { level: 3 } }
]
},
paragraph: {
group: 'block',
content: 'inline*',
toDOM: () => ['p', 0],
parseDOM: [{ tag: 'p' }]
},
codeBlock: {
group: 'block',
content: 'text*',
marks: '',
attrs: { language: { default: null } },
toDOM: () => ['pre', ['code', 0]],
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }]
},
mermaid: {
group: 'block',
content: 'text*',
marks: '',
attrs: { language: { default: 'mermaid' } },
toDOM: () => ['div', { class: 'mermaid-diagram' }, ['code', 0]],
parseDOM: [{ tag: 'div.mermaid-diagram', preserveWhitespace: 'full' }]
}
},
marks: {}
})
}
function makeClipboardData (data: { plain?: string, markdown?: string, types?: string[] }): any {
const plain = data.plain ?? ''
const markdown = data.markdown ?? ''
const types = data.types ?? ['text/plain']
return {
types,
getData: (t: string) => {
if (t === 'text/plain') return plain
if (t === 'text/markdown') return markdown
return ''
}
} as any
}
describe('SmartPaste handlePaste ignore contexts', () => {
it('ignores smart paste when selection is inside a codeBlock', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [
schema.node('codeBlock', { language: 'mermaid' }, schema.text('graph TD\nA-->B'))
])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
it('ignores smart paste when selection is inside a mermaid block', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('graph TD\nA-->B'))])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
it('ignores smart paste when NodeSelection is a codeBlock', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('codeBlock', undefined, schema.text('x'))])
const state = EditorState.create({
schema,
doc,
selection: NodeSelection.create(doc, 0)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
it('ignores smart paste when NodeSelection is a mermaid node', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('x'))])
const state = EditorState.create({
schema,
doc,
selection: NodeSelection.create(doc, 0)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
})
describe('SmartPaste handlePaste transform scenarios', () => {
it('transforms plain text paste into markdown output in normal text selection', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const dispatch = jest.fn()
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch },
{ clipboardData: makeClipboardData({ plain: '# Title', types: ['text/plain'] }) },
null
)
expect(handled).toBe(true)
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('transforms when explicit markdown is present even with rich clipboard types', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const dispatch = jest.fn()
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch },
{
clipboardData: makeClipboardData({
plain: 'fallback',
markdown: '## Heading',
types: ['text/html', 'text/markdown']
})
},
null
)
expect(handled).toBe(true)
expect(dispatch).toHaveBeenCalledTimes(1)
})
})
@@ -17,7 +17,7 @@ import { MarkupMarkType, MarkupNodeType, type MarkupNode } from '@hcengineering/
import { markdownToMarkup } from '@hcengineering/text-markdown'
import { Extension } from '@tiptap/core'
import { Node, type Schema } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state'
import { NodeSelection, Plugin } from '@tiptap/pm/state'
import { CodeBlockHighlighExtension } from '../codeSnippets/codeblock'
import { hasTableMetadataMarker } from './tableMetadata'
@@ -29,7 +29,7 @@ export const SmartPasteExtension = Extension.create({
}
})
function PasteTextAsMarkdownPlugin (): Plugin {
export function PasteTextAsMarkdownPlugin (): Plugin {
return new Plugin({
props: {
handlePaste (view, event, slice) {
@@ -39,12 +39,16 @@ function PasteTextAsMarkdownPlugin (): Plugin {
const pastedText = clipboardData.getData('text/plain')
const pastedMarkdown = clipboardData.getData('text/markdown')
// check if we are in code block
const { $from } = view.state.selection
// Ignore smart paste inside code blocks / mermaid blocks (keep default paste behavior).
const selection = view.state.selection
const ignoredNodeTypes = new Set<string>([CodeBlockHighlighExtension.name, 'mermaid'])
if (selection instanceof NodeSelection && ignoredNodeTypes.has(selection.node.type.name)) {
return false
}
const { $from } = selection
for (let d = $from.depth; d > 0; d--) {
const node = $from.node(d)
if (node.type.name === CodeBlockHighlighExtension.name) {
// paste as plain text in code blocks
if (ignoredNodeTypes.has(node.type.name)) {
return false
}
}
+2 -1
View File
@@ -279,7 +279,8 @@
"Extensions": "Rozšíření",
"UnsetParentIssue": "Odebrat nadřazený úkol",
"ForbidCreateProjectPermission": "Zakázat vytvoření projektu",
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty"
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty",
"AllowCreatingIssues": "Povolit vytváření úkolů"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "Erweiterungen",
"UnsetParentIssue": "Übergeordnete Aufgabe entfernen",
"ForbidCreateProjectPermission": "Projekterstellung verbieten",
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte"
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte",
"AllowCreatingIssues": "Erstellen von Aufgaben erlauben"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Forbid create project",
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects"
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects",
"AllowCreatingIssues": "Allow creating issues"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Prohibir crear proyecto",
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos"
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos",
"AllowCreatingIssues": "Permitir crear incidencias"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Désélectionner l'issue parent",
"ForbidCreateProjectPermission": "Interdire la création de projet",
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets"
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets",
"AllowCreatingIssues": "Autoriser la création d'issues"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Estensioni",
"UnsetParentIssue": "Annulla l'issue genitore",
"ForbidCreateProjectPermission": "Vieta creazione progetto",
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti"
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti",
"AllowCreatingIssues": "Consenti la creazione di issue"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "拡張機能",
"UnsetParentIssue": "親イシューの設定を解除",
"ForbidCreateProjectPermission": "プロジェクト作成禁止",
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します"
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します",
"AllowCreatingIssues": "イシューの作成を許可"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos"
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
"AllowCreatingIssues": "Permitir criar problemas"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos"
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
"AllowCreatingIssues": "Permitir criar problemas"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "Дополнительно",
"UnsetParentIssue": "Снять родительскую задачу",
"ForbidCreateProjectPermission": "Запретить создание проекта",
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты"
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты",
"AllowCreatingIssues": "Разрешить создание задач"
},
"status": {}
}
+2 -1
View File
@@ -270,7 +270,8 @@
"DefaultIssueStatus": "Varsayılan sorun durumu",
"IssueStatus": "Durum",
"Extensions": "Uzantılar",
"UnsetParentIssue": "Üst sorunu kaldır"
"UnsetParentIssue": "Üst sorunu kaldır",
"AllowCreatingIssues": "Sorun oluşturmaya izin ver"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "扩展",
"UnsetParentIssue": "取消父问题",
"ForbidCreateProjectPermission": "禁止创建项目",
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目"
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目",
"AllowCreatingIssues": "允许创建问题"
},
"status": {}
}

Some files were not shown because too many files have changed in this diff Show More