diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5859efb89b..d672a923bc 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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 diff --git a/desktop/src/__test__/main/config.test.ts b/desktop/src/__test__/main/config.test.ts index 9e1e3c3c09..7b66d1c9f1 100644 --- a/desktop/src/__test__/main/config.test.ts +++ b/desktop/src/__test__/main/config.test.ts @@ -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', () => ({ diff --git a/desktop/src/__test__/main/findInPage.test.ts b/desktop/src/__test__/main/findInPage.test.ts new file mode 100644 index 0000000000..738141f41e --- /dev/null +++ b/desktop/src/__test__/main/findInPage.test.ts @@ -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() + +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') + }) +}) diff --git a/desktop/src/__test__/main/path.test.ts b/desktop/src/__test__/main/path.test.ts new file mode 100644 index 0000000000..6aa1008247 --- /dev/null +++ b/desktop/src/__test__/main/path.test.ts @@ -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') + ) + }) +}) diff --git a/desktop/src/main/args.ts b/desktop/src/main/args.ts index b118841bac..67225c656b 100644 --- a/desktop/src/main/args.ts +++ b/desktop/src/main/args.ts @@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander' program .name('Huly') .allowUnknownOption() + .allowExcessArguments(true) .option('-s, --server ', 'Remote server URL (front). E.g. https://huly.app') let opts: OptionValues | null = null diff --git a/desktop/src/main/config.ts b/desktop/src/main/config.ts index f371ee73e0..1f0de4353f 100644 --- a/desktop/src/main/config.ts +++ b/desktop/src/main/config.ts @@ -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()) } diff --git a/desktop/src/main/customMenu.ts b/desktop/src/main/customMenu.ts index ef28ed9b35..7bae4d2ef4 100644 --- a/desktop/src/main/customMenu.ts +++ b/desktop/src/main/customMenu.ts @@ -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 diff --git a/desktop/src/main/findInPage.ts b/desktop/src/main/findInPage.ts new file mode 100644 index 0000000000..eb9cb844d0 --- /dev/null +++ b/desktop/src/main/findInPage.ts @@ -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() +/** Page webContents id → overlay that should receive `found-in-page` IPC. */ +const findResultRecipientByPageId = new Map() +const pagesWithFoundInPageListener = new WeakSet() + +const overlayViewsByWindowId = new Map() +const overlayVisibleByWindowId = new Map() + +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() + }) +} diff --git a/desktop/src/main/findInPageOverlayHost.ts b/desktop/src/main/findInPageOverlayHost.ts new file mode 100644 index 0000000000..38d60ea6ae --- /dev/null +++ b/desktop/src/main/findInPageOverlayHost.ts @@ -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 { + 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) + } +} diff --git a/desktop/src/main/path.ts b/desktop/src/main/path.ts index 5de505a45f..4912c98626 100644 --- a/desktop/src/main/path.ts +++ b/desktop/src/main/path.ts @@ -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) } diff --git a/desktop/src/main/standardMenu.ts b/desktop/src/main/standardMenu.ts index 2cbc0b7d10..c8f794224e 100644 --- a/desktop/src/main/standardMenu.ts +++ b/desktop/src/main/standardMenu.ts @@ -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' } diff --git a/desktop/src/main/start.ts b/desktop/src/main/start.ts index ffe16d40ff..44198c3aac 100644 --- a/desktop/src/main/start.ts +++ b/desktop/src/main/start.ts @@ -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 diff --git a/desktop/src/ui/find-in-page-overlay.ejs b/desktop/src/ui/find-in-page-overlay.ejs new file mode 100644 index 0000000000..f7b5a230d5 --- /dev/null +++ b/desktop/src/ui/find-in-page-overlay.ejs @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/desktop/src/ui/findInPageBar.ts b/desktop/src/ui/findInPageBar.ts new file mode 100644 index 0000000000..f5eb706e7f --- /dev/null +++ b/desktop/src/ui/findInPageBar.ts @@ -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 | 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 { + 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; +} +` diff --git a/desktop/src/ui/findInPageOverlay.ts b/desktop/src/ui/findInPageOverlay.ts new file mode 100644 index 0000000000..9105696b4c --- /dev/null +++ b/desktop/src/ui/findInPageOverlay.ts @@ -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) + } +}) diff --git a/desktop/src/ui/ipcMessages.ts b/desktop/src/ui/ipcMessages.ts index 759c593d35..40b15a0b4d 100644 --- a/desktop/src/ui/ipcMessages.ts +++ b/desktop/src/ui/ipcMessages.ts @@ -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 diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index 57fe324203..2367e56af9 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -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) diff --git a/desktop/src/ui/titleBarMenu.ts b/desktop/src/ui/titleBarMenu.ts index 601bbd2148..b7cc7d7d3c 100644 --- a/desktop/src/ui/titleBarMenu.ts +++ b/desktop/src/ui/titleBarMenu.ts @@ -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 diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 7141dbc2da..d2c018d31a 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -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 onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => void + + onOpenFindBar: (callback: () => void) => void + findInPage: (text: string, options?: DesktopFindInPageOptions) => Promise + stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => Promise + 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 +} diff --git a/desktop/webpack.config.js b/desktop/webpack.config.js index 1ac4436d49..d955e51256 100644 --- a/desktop/webpack.config.js +++ b/desktop/webpack.config.js @@ -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' diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index 2551c951cd..57c92ef3a3 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -582,6 +582,19 @@ export interface ClassPermission extends Permission { targetClass: Ref> } +/** + * @public + */ +export interface ModulePermissionGroup extends Doc { + application: Ref + role: AccountRole + permissions: Ref[] + disabledPermissions?: Ref[] + spaceClass: Ref> + enabled: boolean + order?: number +} + /** * @public */ diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts index fc095f1c65..5d132e5098 100644 --- a/foundations/core/packages/core/src/component.ts +++ b/foundations/core/packages/core/src/component.ts @@ -43,6 +43,7 @@ import type { MarkupBlobRef, MigrationState, Mixin, + ModulePermissionGroup, Obj, Permission, PersonId, @@ -180,7 +181,8 @@ export default plugin(coreId, { Sequence: '' as Ref>, CustomSequence: '' as Ref>, ClassCollaborators: '' as Ref>>, - Collaborator: '' as Ref> + Collaborator: '' as Ref>, + ModulePermissionGroup: '' as Ref> }, 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, diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index 13a2498807..077d546a84 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -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>>> +} + export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware { + private permissionsCache: GuestPermissionsCache | undefined = undefined + private initPromise: Promise | 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 { + 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 { + try { + const docs = await this.findAll(ctx, core.class.ModulePermissionGroup, {}, {}) + if (docs.length > 0) { + const rolePermissions = new Map>>() + const allPermissionIds = new Set>() + 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[] + const disabled = new Set>((group.disabledPermissions ?? []) as Ref[]) + const current = rolePermissions.get(role) ?? new Set>() + 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>, + { _id: { $in: Array.from(allPermissionIds) } } as any + ) + : [] + const permissionToClass = new Map, Ref>>( + classPermissions + .map( + (permission) => [permission._id as Ref, (permission as ClassPermission).targetClass] as const + ) + .filter((entry): entry is readonly [Ref, Ref>] => entry[1] !== undefined) + ) + const roleAllowedClasses = new Map>>>() + for (const [role, permissions] of rolePermissions.entries()) { + const allowedClasses = new Set>>() + 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 + if (cudTx.objectClass === core.class.ModulePermissionGroup) { + this.permissionsCache = undefined + return + } + } + } + } + async tx (ctx: MeasureContext, txes: Tx[]): Promise { 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>, + allowedClasses: Set>> + ): Ref> | 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, account: Account): Promise { + 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, account: Account): Promise { 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>>() + 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, account: Account): Promise { diff --git a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts new file mode 100644 index 0000000000..e124aee8cb --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts @@ -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> +const UNCOVERED_CLASS = 'test:class:UncoveredClass' as Ref> +const COVERED_CLASS_PERMISSION = 'test:permission:CoveredClassPermission' as Ref +const MODULE_PERMISSION_GROUP_CLASS = core.class.ModulePermissionGroup +const ALLOWED_SPACE = 'test:space:Allowed' as Ref +const FORBIDDEN_SPACE = 'test:space:Forbidden' as Ref + +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 { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} } + } as any + return ctx +} + +type FindAllFn = (ctx: MeasureContext, _class: Ref>, query: object, options?: object) => Promise + +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 +): 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>, objectSpace: Ref): 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[], disabledPermissions?: Ref[]): Doc { + return { + _id: generateId(), + _class: MODULE_PERMISSION_GROUP_CLASS, + space: 'core:space:Workspace' as Ref, + modifiedOn: Date.now(), + modifiedBy: 'test' as PersonId, + application: 'test:app:tracker' as Ref, + role: AccountRole.Guest, + permissions: allowedPermissions, + ...(disabledPermissions !== undefined && disabledPermissions.length > 0 ? { disabledPermissions } : {}), + spaceClass: 'core:class:Space' as Ref>, + 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 + } 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() + }) + }) +}) diff --git a/models/card/src/index.ts b/models/card/src/index.ts index 49a1bc8fac..9fb5c5afcc 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -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'] diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 9210327830..41c0cb6cff 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -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, diff --git a/models/chunter/src/plugin.ts b/models/chunter/src/plugin.ts index 8dd737ffef..7bffa1ca41 100644 --- a/models/chunter/src/plugin.ts +++ b/models/chunter/src/plugin.ts @@ -94,7 +94,8 @@ export default mergeIds(chunterId, chunter, { Channels: '' as Ref }, ids: { - ChunterNotificationGroup: '' as Ref + ChunterNotificationGroup: '' as Ref, + ModulePermissionGroup: '' as Ref }, space: { General: '' as Ref, diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index fb4860d61a..be34b62b2c 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -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) diff --git a/models/controlled-documents/src/plugin.ts b/models/controlled-documents/src/plugin.ts index b82f64e56a..ccf4155a91 100644 --- a/models/controlled-documents/src/plugin.ts +++ b/models/controlled-documents/src/plugin.ts @@ -91,5 +91,8 @@ export default mergeIds(documentsId, documents, { DocumentsNotificationGroup: '' as Ref, ContentNotification: '' as Ref, StateNotification: '' as Ref + }, + ids: { + ModulePermissionGroup: '' as Ref } }) diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 27423777bd..cebdc42862 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -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, diff --git a/models/core/src/security.ts b/models/core/src/security.ts index d6481d720c..186d008c47 100644 --- a/models/core/src/security.ts +++ b/models/core/src/security.ts @@ -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 + + @Prop(TypeString(), core.string.Roles) + role!: AccountRole + + @Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission) + permissions!: Ref[] + + @Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission) + disabledPermissions?: Ref[] + + @Prop(TypeRef(core.class.Class), core.string.Class) + spaceClass!: Ref> + + @Prop(TypeBoolean(), core.string.Name) + enabled!: boolean + + @Prop(TypeNumber(), core.string.Order) + order?: number +} diff --git a/models/document/src/index.ts b/models/document/src/index.ts index 75d046bb4f..285f46bdd5 100644 --- a/models/document/src/index.ts +++ b/models/document/src/index.ts @@ -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, { diff --git a/models/document/src/plugin.ts b/models/document/src/plugin.ts index 78b3ce3f2d..71344870c2 100644 --- a/models/document/src/plugin.ts +++ b/models/document/src/plugin.ts @@ -61,6 +61,9 @@ export default mergeIds(documentId, document, { Document: '' as Ref, Other: '' as Ref }, + ids: { + ModulePermissionGroup: '' as Ref + }, string: { ConfigDescription: '' as IntlString, ParentDocument: '' as IntlString, diff --git a/models/drive/src/index.ts b/models/drive/src/index.ts index 5614644a89..c0a1e39922 100644 --- a/models/drive/src/index.ts +++ b/models/drive/src/index.ts @@ -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) } diff --git a/models/drive/src/plugin.ts b/models/drive/src/plugin.ts index d2738d1f23..aee47f4d9c 100644 --- a/models/drive/src/plugin.ts +++ b/models/drive/src/plugin.ts @@ -101,6 +101,9 @@ export default mergeIds(driveId, drive, { RenameFolder: '' as ViewAction, RestoreFileVersion: '' as ViewAction }, + ids: { + ModulePermissionGroup: '' as Ref + }, string: { Grid: '' as IntlString, Name: '' as IntlString, diff --git a/models/love/src/index.ts b/models/love/src/index.ts index 97abdabe05..0e67856e1d 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -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, diff --git a/models/love/src/plugin.ts b/models/love/src/plugin.ts index 1c54922679..72acc0f5e4 100644 --- a/models/love/src/plugin.ts +++ b/models/love/src/plugin.ts @@ -50,7 +50,8 @@ export default mergeIds(loveId, love, { ids: { Settings: '' as Ref, LoveNotificationGroup: '' as Ref, - MeetingMinutesChatNotification: '' as Ref + MeetingMinutesChatNotification: '' as Ref, + ModulePermissionGroup: '' as Ref }, function: { MeetingMinutesTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise> diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 09a7404caf..1924f6bda0 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -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 + ) 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, diff --git a/models/test-management/src/index.ts b/models/test-management/src/index.ts index 1d334eeeaf..3252a2fc2e 100644 --- a/models/test-management/src/index.ts +++ b/models/test-management/src/index.ts @@ -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 diff --git a/models/test-management/src/plugin.ts b/models/test-management/src/plugin.ts index 7a4335ff90..a5d21836c1 100644 --- a/models/test-management/src/plugin.ts +++ b/models/test-management/src/plugin.ts @@ -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 } }) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 1f45a0e400..54c2dedd55 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -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, diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 51136bdb00..cd9f7faa15 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -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, AssigneeNotification: '' as Ref, BaseProjectType: '' as Ref, + GuestIssueClassPermission: '' as Ref, + ModulePermissionGroup: '' as Ref, IssueUpdatedActivityViewlet: '' as Ref, IssueCreatedActivityViewlet: '' as Ref, IssueRemovedActivityViewlet: '' as Ref, diff --git a/models/training/src/index.ts b/models/training/src/index.ts index b91f8686cd..7c612d7ac9 100644 --- a/models/training/src/index.ts +++ b/models/training/src/index.ts @@ -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 = { diff --git a/models/training/src/plugin.ts b/models/training/src/plugin.ts index b75a6c85e6..86071cd6a2 100644 --- a/models/training/src/plugin.ts +++ b/models/training/src/plugin.ts @@ -33,6 +33,10 @@ export default mergeIds(trainingId, training, { TrainingGroup: '' as Ref, TrainingRequest: '' as Ref }, + ids: { + GuestTrainingAttemptClassPermission: '' as Ref, + ModulePermissionGroup: '' as Ref + }, // TODO: Move function resources declarations to plugins/*-resources // Currently, dependencies look like this: diff --git a/plugins/card-assets/lang/cs.json b/plugins/card-assets/lang/cs.json index 8bec71cbf1..1f98733f46 100644 --- a/plugins/card-assets/lang/cs.json +++ b/plugins/card-assets/lang/cs.json @@ -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" } } diff --git a/plugins/card-assets/lang/de.json b/plugins/card-assets/lang/de.json index c8186ed0c9..f3c81b06e8 100644 --- a/plugins/card-assets/lang/de.json +++ b/plugins/card-assets/lang/de.json @@ -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" } } diff --git a/plugins/card-assets/lang/en.json b/plugins/card-assets/lang/en.json index a8a663ef4b..9cdf62091b 100644 --- a/plugins/card-assets/lang/en.json +++ b/plugins/card-assets/lang/en.json @@ -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" } } diff --git a/plugins/card-assets/lang/es.json b/plugins/card-assets/lang/es.json index e16b345a64..68ad24ea6a 100644 --- a/plugins/card-assets/lang/es.json +++ b/plugins/card-assets/lang/es.json @@ -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" } } diff --git a/plugins/card-assets/lang/fr.json b/plugins/card-assets/lang/fr.json index 5c7860dc38..72dbd7fab7 100644 --- a/plugins/card-assets/lang/fr.json +++ b/plugins/card-assets/lang/fr.json @@ -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" } } diff --git a/plugins/card-assets/lang/it.json b/plugins/card-assets/lang/it.json index 1a9350729e..e3ab22ad94 100644 --- a/plugins/card-assets/lang/it.json +++ b/plugins/card-assets/lang/it.json @@ -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" } } diff --git a/plugins/card-assets/lang/ja.json b/plugins/card-assets/lang/ja.json index 922096b521..46e77c01b7 100644 --- a/plugins/card-assets/lang/ja.json +++ b/plugins/card-assets/lang/ja.json @@ -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列" } } diff --git a/plugins/card-assets/lang/pt-br.json b/plugins/card-assets/lang/pt-br.json index 2c98aa1bd8..c3392a5697 100644 --- a/plugins/card-assets/lang/pt-br.json +++ b/plugins/card-assets/lang/pt-br.json @@ -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" } } diff --git a/plugins/card-assets/lang/pt.json b/plugins/card-assets/lang/pt.json index 9e5c8a85cd..59fb646306 100644 --- a/plugins/card-assets/lang/pt.json +++ b/plugins/card-assets/lang/pt.json @@ -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" } } diff --git a/plugins/card-assets/lang/ru.json b/plugins/card-assets/lang/ru.json index 6244a6591b..0d318ab36c 100644 --- a/plugins/card-assets/lang/ru.json +++ b/plugins/card-assets/lang/ru.json @@ -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": "Два столбца" } } diff --git a/plugins/card-assets/lang/tr.json b/plugins/card-assets/lang/tr.json index 22048c8250..b90c4a32de 100644 --- a/plugins/card-assets/lang/tr.json +++ b/plugins/card-assets/lang/tr.json @@ -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" } } diff --git a/plugins/card-assets/lang/zh.json b/plugins/card-assets/lang/zh.json index f6e0071bbb..e2bff46383 100644 --- a/plugins/card-assets/lang/zh.json +++ b/plugins/card-assets/lang/zh.json @@ -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": "两列" } } diff --git a/plugins/card-resources/src/components/CardAttributeEditor.svelte b/plugins/card-resources/src/components/CardAttributeEditor.svelte index 75e87a9eff..b5c7766d56 100644 --- a/plugins/card-resources/src/components/CardAttributeEditor.svelte +++ b/plugins/card-resources/src/components/CardAttributeEditor.svelte @@ -13,13 +13,14 @@ // limitations under the License. --> @@ -224,6 +258,14 @@ +