From f47b42b8647842a48f2887cb3c7de2e850707f09 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 1 Apr 2026 15:19:57 +0700 Subject: [PATCH 01/10] Do not log workspace info (#10712) Signed-off-by: Artem Savchenko --- plugins/workbench-resources/src/connect.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/workbench-resources/src/connect.ts b/plugins/workbench-resources/src/connect.ts index 3c2d38b13a..0b6b13a49b 100644 --- a/plugins/workbench-resources/src/connect.ts +++ b/plugins/workbench-resources/src/connect.ts @@ -127,8 +127,6 @@ export async function connect (title: string): Promise { break } - console.log('workspaceLoginInfo', workspaceLoginInfo) - const token = workspaceLoginInfo.token setMetadata(presentation.metadata.Token, workspaceLoginInfo.token) From 38df5a3cdb2529248eb1cb15be0f18ffc3d7253a Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Wed, 1 Apr 2026 14:43:22 +0500 Subject: [PATCH 02/10] Change icons (#10711) Signed-off-by: Denis Bykhov --- .../card-resources/src/components/MasterTagAttributes.svelte | 2 +- plugins/card-resources/src/components/TagAttributes.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/card-resources/src/components/MasterTagAttributes.svelte b/plugins/card-resources/src/components/MasterTagAttributes.svelte index 6e7b6aa46c..3224777391 100644 --- a/plugins/card-resources/src/components/MasterTagAttributes.svelte +++ b/plugins/card-resources/src/components/MasterTagAttributes.svelte @@ -76,7 +76,7 @@ {#if (isLocked && canUnlock) || (!isLocked && canLock)}
+
+ attributeUpdated('singleColumn', e.detail)} + /> +
{/if} diff --git a/plugins/card-resources/src/plugin.ts b/plugins/card-resources/src/plugin.ts index 799216f195..26160901a8 100644 --- a/plugins/card-resources/src/plugin.ts +++ b/plugins/card-resources/src/plugin.ts @@ -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 } }) diff --git a/plugins/card-resources/src/utils.ts b/plugins/card-resources/src/utils.ts index a361cace23..4624bd644e 100644 --- a/plugins/card-resources/src/utils.ts +++ b/plugins/card-resources/src/utils.ts @@ -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 { if (tag !== undefined) { @@ -780,3 +781,15 @@ export function canUnlockSection (space: Ref, store: PermissionsStore): b if (allowed) return true return !store.restrictedSpaces.has(space) } + +export const viewStore = writable, string>>( + JSON.parse(localStorage.getItem('card.layout') ?? '{}') +) + +export function setViewMode (type: Ref, mode: string): void { + viewStore.update((views) => { + views[type] = mode + localStorage.setItem('card.layout', JSON.stringify(views)) + return views + }) +} diff --git a/plugins/card/src/index.ts b/plugins/card/src/index.ts index 6adfa1054f..c18268f773 100644 --- a/plugins/card/src/index.ts +++ b/plugins/card/src/index.ts @@ -39,6 +39,7 @@ export interface MasterTag extends Class { background?: number removed?: boolean roles?: CollectionSize + singleColumn?: boolean } export interface Tag extends MasterTag, Mixin {} diff --git a/plugins/view-resources/src/components/PersonIdPresenter.svelte b/plugins/view-resources/src/components/PersonIdPresenter.svelte index be19da6a9d..badfe996f6 100644 --- a/plugins/view-resources/src/components/PersonIdPresenter.svelte +++ b/plugins/view-resources/src/components/PersonIdPresenter.svelte @@ -39,13 +39,15 @@ {#if person} - +
+ +
{/if} From ea254970c0d30ebaa5240221d13aea8ced82e7a7 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 5 Apr 2026 14:36:59 +0700 Subject: [PATCH 05/10] feat: Add ability to configure guest permissions (#10708) * Configure guest permissions Signed-off-by: Artem Savchenko * Fix permission domain Signed-off-by: Artem Savchenko * Fix permission declaration Signed-off-by: Artem Savchenko * Fix lock file Signed-off-by: Artem Savchenko * Guest permissions Signed-off-by: Artem Savchenko * Allow guests to update their own documents Signed-off-by: Artem Savchenko * Add modules order Signed-off-by: Artem Savchenko * Fix translations, icons Signed-off-by: Artem Savchenko * Fix disabled apps and update translations Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- foundations/core/packages/core/src/classes.ts | 13 + .../core/packages/core/src/component.ts | 5 +- .../middleware/src/guestPermissions.ts | 146 +++++- .../src/tests/guestPermissions.test.ts | 491 ++++++++++++++++++ models/card/src/index.ts | 25 + models/chunter/src/index.ts | 14 + models/chunter/src/plugin.ts | 3 +- models/controlled-documents/src/index.ts | 13 + models/controlled-documents/src/plugin.ts | 3 + models/core/src/index.ts | 2 + models/core/src/security.ts | 26 + models/document/src/index.ts | 13 + models/document/src/plugin.ts | 3 + models/drive/src/index.ts | 13 + models/drive/src/plugin.ts | 3 + models/love/src/index.ts | 14 + models/love/src/plugin.ts | 3 +- models/setting/src/index.ts | 14 + models/test-management/src/index.ts | 15 +- models/test-management/src/plugin.ts | 5 +- models/tracker/src/index.ts | 25 + models/tracker/src/plugin.ts | 5 +- models/training/src/index.ts | 25 + models/training/src/plugin.ts | 4 + plugins/card-assets/lang/cs.json | 1 + plugins/card-assets/lang/de.json | 1 + plugins/card-assets/lang/en.json | 1 + plugins/card-assets/lang/es.json | 1 + plugins/card-assets/lang/fr.json | 1 + plugins/card-assets/lang/it.json | 1 + plugins/card-assets/lang/ja.json | 1 + plugins/card-assets/lang/pt-br.json | 1 + plugins/card-assets/lang/pt.json | 1 + plugins/card-assets/lang/ru.json | 1 + plugins/card-assets/lang/tr.json | 1 + plugins/card-assets/lang/zh.json | 1 + plugins/card/src/index.ts | 5 +- plugins/setting-assets/assets/icons.svg | 6 + plugins/setting-assets/lang/cs.json | 3 + plugins/setting-assets/lang/de.json | 3 + plugins/setting-assets/lang/en.json | 3 + plugins/setting-assets/lang/es.json | 3 + plugins/setting-assets/lang/fr.json | 3 + plugins/setting-assets/lang/it.json | 3 + plugins/setting-assets/lang/ja.json | 3 + plugins/setting-assets/lang/pt-br.json | 3 + plugins/setting-assets/lang/pt.json | 3 + plugins/setting-assets/lang/ru.json | 3 + plugins/setting-assets/lang/tr.json | 3 + plugins/setting-assets/lang/zh.json | 3 + plugins/setting-assets/src/index.ts | 1 + .../GuestPermissionsSettings.svelte | 376 ++++++++++++++ plugins/setting-resources/src/index.ts | 2 + plugins/setting-resources/src/plugin.ts | 3 +- plugins/setting/src/index.ts | 4 + plugins/tracker-assets/lang/cs.json | 3 +- plugins/tracker-assets/lang/de.json | 3 +- plugins/tracker-assets/lang/en.json | 3 +- plugins/tracker-assets/lang/es.json | 3 +- plugins/tracker-assets/lang/fr.json | 3 +- plugins/tracker-assets/lang/it.json | 3 +- plugins/tracker-assets/lang/ja.json | 3 +- plugins/tracker-assets/lang/pt-br.json | 3 +- plugins/tracker-assets/lang/pt.json | 3 +- plugins/tracker-assets/lang/ru.json | 3 +- plugins/tracker-assets/lang/tr.json | 3 +- plugins/tracker-assets/lang/zh.json | 3 +- plugins/training-assets/lang/cs.json | 1 + plugins/training-assets/lang/de.json | 1 + plugins/training-assets/lang/en.json | 1 + plugins/training-assets/lang/es.json | 1 + plugins/training-assets/lang/fr.json | 1 + plugins/training-assets/lang/it.json | 1 + plugins/training-assets/lang/ja.json | 1 + plugins/training-assets/lang/pt-br.json | 1 + plugins/training-assets/lang/pt.json | 1 + plugins/training-assets/lang/ru.json | 1 + plugins/training-assets/lang/tr.json | 1 + plugins/training-assets/lang/zh.json | 1 + plugins/training/src/index.ts | 1 + .../src/components/Applications.svelte | 46 +- 81 files changed, 1390 insertions(+), 33 deletions(-) create mode 100644 foundations/server/packages/middleware/src/tests/guestPermissions.test.ts create mode 100644 plugins/setting-resources/src/components/GuestPermissionsSettings.svelte 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 c6571b4dc5..9fb5c5afcc 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -922,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 a6327b4375..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", diff --git a/plugins/card-assets/lang/de.json b/plugins/card-assets/lang/de.json index 5bea775872..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", diff --git a/plugins/card-assets/lang/en.json b/plugins/card-assets/lang/en.json index aff2133379..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", diff --git a/plugins/card-assets/lang/es.json b/plugins/card-assets/lang/es.json index 2a4b1bd419..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", diff --git a/plugins/card-assets/lang/fr.json b/plugins/card-assets/lang/fr.json index cd5fb22f5a..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", diff --git a/plugins/card-assets/lang/it.json b/plugins/card-assets/lang/it.json index d58a10abfe..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", diff --git a/plugins/card-assets/lang/ja.json b/plugins/card-assets/lang/ja.json index 83c76dfe8c..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": "タイプ", diff --git a/plugins/card-assets/lang/pt-br.json b/plugins/card-assets/lang/pt-br.json index 1694d3ae14..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", diff --git a/plugins/card-assets/lang/pt.json b/plugins/card-assets/lang/pt.json index 5f45ef5586..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", diff --git a/plugins/card-assets/lang/ru.json b/plugins/card-assets/lang/ru.json index 1075fcc562..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": "Тип", diff --git a/plugins/card-assets/lang/tr.json b/plugins/card-assets/lang/tr.json index 1b1e3b377d..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", diff --git a/plugins/card-assets/lang/zh.json b/plugins/card-assets/lang/zh.json index 9c37368aac..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": "类型", diff --git a/plugins/card/src/index.ts b/plugins/card/src/index.ts index c18268f773..5184d80009 100644 --- a/plugins/card/src/index.ts +++ b/plugins/card/src/index.ts @@ -206,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, @@ -223,7 +224,9 @@ const cardPlugin = plugin(cardId, { CommunicationMessages: '' as Ref }, ids: { - CardWidget: '' as Ref + CardWidget: '' as Ref, + GuestCardClassPermission: '' as Ref, + ModulePermissionGroup: '' as Ref }, component: { LabelsPresenter: '' as AnyComponent, diff --git a/plugins/setting-assets/assets/icons.svg b/plugins/setting-assets/assets/icons.svg index dcb67eaa21..8032dcfa86 100644 --- a/plugins/setting-assets/assets/icons.svg +++ b/plugins/setting-assets/assets/icons.svg @@ -9,6 +9,12 @@ + + + + + + diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index 5f86305ff5..9cd84e9c53 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -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", diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index 0a7e00ceb9..77cfe2c0ba 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -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", diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index f235562cbd..7ec7b53bea 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -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", diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index d2588bb0cc..de7d47925c 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -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", diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 544d315a63..72f43b6b15 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -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", diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index a24b761625..030ecb9e8c 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -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", diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index bd4c6c83af..471f3d444d 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -214,6 +214,9 @@ "OfficeDefaultSettings": "会議室のデフォルト設定", "DefaultStartWithTranscription": "新しいオフィスルームで文字起こしを有効にする", "DefaultStartWithRecording": "新しいオフィスルームで録画を有効にする", + "GuestPermissionsSettings": "ゲストの権限", + "GuestPermissionsModulePermissions": "モジュールの権限", + "GuestPermissionsModulePermissionsHint": "ゲストが利用できるモジュールを選び、その下で各アプリの権限を調整します。", "ImportDocumentPermission": "ドキュメントをインポート", "ImportDocumentDescription": "ユーザーにワークスペースにドキュメントをインポートする機能を付与します", "SelectUsers": "ユーザーを選択", diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 05b1bc7ada..1a061058bd 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -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", diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index fb80fd05d8..a44599991b 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -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", diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 1af522701c..7c61a771c5 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -215,6 +215,9 @@ "OfficeDefaultSettings": "Настройки по умолчанию для переговорных", "DefaultStartWithTranscription": "Включить транскрипцию в новых комнатах", "DefaultStartWithRecording": "Включить запись в новых комнатах", + "GuestPermissionsSettings": "Права гостей", + "GuestPermissionsModulePermissions": "Права модулей", + "GuestPermissionsModulePermissionsHint": "Выберите, какие модули доступны гостям, затем настройте права для каждого приложения ниже.", "ImportDocumentPermission": "Импорт документов", "ImportDocumentDescription": "Предоставляет пользователям возможность импортировать документы в рабочее пространство", "SelectUsers": "Выбрать пользователей", diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 9b92fe4f11..a14577706d 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -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ç", diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index 1146fc9f78..d55a63ba8b 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -214,6 +214,9 @@ "OfficeDefaultSettings": "会议室的默认设置", "DefaultStartWithTranscription": "在新办公室启用转录", "DefaultStartWithRecording": "在新办公室启用录制", + "GuestPermissionsSettings": "访客权限", + "GuestPermissionsModulePermissions": "模块权限", + "GuestPermissionsModulePermissionsHint": "选择访客可以使用哪些模块,然后在下方调整每个应用的权限。", "ImportDocumentPermission": "导入文档", "ImportDocumentDescription": "授予用户将文档导入工作区的权限", "SelectUsers": "选择用户", diff --git a/plugins/setting-assets/src/index.ts b/plugins/setting-assets/src/index.ts index 7629c77144..93b84f885d 100644 --- a/plugins/setting-assets/src/index.ts +++ b/plugins/setting-assets/src/index.ts @@ -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`, diff --git a/plugins/setting-resources/src/components/GuestPermissionsSettings.svelte b/plugins/setting-resources/src/components/GuestPermissionsSettings.svelte new file mode 100644 index 0000000000..ad0852f780 --- /dev/null +++ b/plugins/setting-resources/src/components/GuestPermissionsSettings.svelte @@ -0,0 +1,376 @@ + + + +
+
+ +
+
+ {#if loading} +
+ +
+ {:else} + +
+
+
+
+
+
+
+
+ +
+ {#each sortedVisibleModuleGroups as group} + {@const app = getApplication(group.application)} + {@const moduleOn = isModuleEnabled(group)} + {@const permissionCount = (group.permissions ?? []).length} +
+
+
+ {#if app} +
+ +
+ {:else} +
+ {/if} +
+
+
+
+
+
+ +
+
+ + {#if permissionCount > 0} +
+ {#each group.permissions ?? [] as permissionId} +
+
+
+
+ +
+
+ {/each} +
+ {/if} +
+ {/each} + {#if visibleModuleGroups.length === 0} +
+ {/if} +
+
+
+
+ {/if} +
+
+ + diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 1f3709f4d7..d6fe25f997 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -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 => ({ CreateRelation, EditRelation, Mailboxes, + GuestPermissionsSettings, OfficeSettings, AddSocialId, AddEmailSocialId, diff --git a/plugins/setting-resources/src/plugin.ts b/plugins/setting-resources/src/plugin.ts index 228f6400df..04648b9574 100644 --- a/plugins/setting-resources/src/plugin.ts +++ b/plugins/setting-resources/src/plugin.ts @@ -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, diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index a22f122c0e..a0c339ef91 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -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, diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index 244a6b4839..f1e9d23d05 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 75576d6c05..99fe946c9d 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index ec5e759cc2..4345dc5a0a 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index cd41e3d4ff..2699c92764 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index fc6cd5fa2a..7b1f0c9466 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 491040cfab..7113d55111 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index 59174a363f..58f184c749 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -272,7 +272,8 @@ "Extensions": "拡張機能", "UnsetParentIssue": "親イシューの設定を解除", "ForbidCreateProjectPermission": "プロジェクト作成禁止", - "ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します" + "ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します", + "AllowCreatingIssues": "イシューの作成を許可" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index 072206019c..828c9ca50b 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index e21aaaaae9..da5a3e85e1 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 564513cc61..642877b385 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -289,7 +289,8 @@ "Extensions": "Дополнительно", "UnsetParentIssue": "Снять родительскую задачу", "ForbidCreateProjectPermission": "Запретить создание проекта", - "ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты" + "ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты", + "AllowCreatingIssues": "Разрешить создание задач" }, "status": {} } diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index cf147f3d99..9f66c50ea5 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -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": {} } diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index f2edf28ea6..8e05a889e1 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -289,7 +289,8 @@ "Extensions": "扩展", "UnsetParentIssue": "取消父问题", "ForbidCreateProjectPermission": "禁止创建项目", - "ForbidCreateProjectPermissionDescription": "禁止用户创建新项目" + "ForbidCreateProjectPermissionDescription": "禁止用户创建新项目", + "AllowCreatingIssues": "允许创建问题" }, "status": {} } diff --git a/plugins/training-assets/lang/cs.json b/plugins/training-assets/lang/cs.json index 6d2c2b6782..e56fd73ec0 100644 --- a/plugins/training-assets/lang/cs.json +++ b/plugins/training-assets/lang/cs.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Koncept", "TrainingStateReleased": "Publikováno", "TrainingTake": "Zúčastnit se školení", + "AllowToTakeTraining": "Povolit účast na školení", "TrainingTitle": "Název", "Trainings": "Školení", "ViewAllTrainings": "Všechna školení", diff --git a/plugins/training-assets/lang/de.json b/plugins/training-assets/lang/de.json index d409b7ab93..515c87ef16 100644 --- a/plugins/training-assets/lang/de.json +++ b/plugins/training-assets/lang/de.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Entwurf", "TrainingStateReleased": "Veröffentlicht", "TrainingTake": "Schulung absolvieren", + "AllowToTakeTraining": "Gästen erlauben, Schulung zu absolvieren", "TrainingTitle": "Titel", "Trainings": "Schulungen", "ViewAllTrainings": "Alle Schulungen", diff --git a/plugins/training-assets/lang/en.json b/plugins/training-assets/lang/en.json index bca815f438..671c993fad 100644 --- a/plugins/training-assets/lang/en.json +++ b/plugins/training-assets/lang/en.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Draft", "TrainingStateReleased": "Released", "TrainingTake": "Take training", + "AllowToTakeTraining": "Allow to take training", "TrainingTitle": "Title", "Trainings": "Trainings", "ViewAllTrainings": "All Trainings", diff --git a/plugins/training-assets/lang/es.json b/plugins/training-assets/lang/es.json index 3f3d482935..41ff32d4c6 100644 --- a/plugins/training-assets/lang/es.json +++ b/plugins/training-assets/lang/es.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Borrador", "TrainingStateReleased": "Publicado", "TrainingTake": "Tomar capacitación", + "AllowToTakeTraining": "Permitir tomar la capacitación", "TrainingTitle": "Título", "Trainings": "Capacitaciones", "ViewAllTrainings": "Todas las Capacitaciones", diff --git a/plugins/training-assets/lang/fr.json b/plugins/training-assets/lang/fr.json index 3855e8cfb0..42ed23e89b 100644 --- a/plugins/training-assets/lang/fr.json +++ b/plugins/training-assets/lang/fr.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Brouillon", "TrainingStateReleased": "Sortie", "TrainingTake": "Suivre la formation", + "AllowToTakeTraining": "Autoriser à suivre la formation", "TrainingTitle": "Titre", "Trainings": "Formations", "ViewAllTrainings": "Toutes les formations", diff --git a/plugins/training-assets/lang/it.json b/plugins/training-assets/lang/it.json index b018eb96a7..48cf25b2d9 100644 --- a/plugins/training-assets/lang/it.json +++ b/plugins/training-assets/lang/it.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Bozza", "TrainingStateReleased": "Rilasciato", "TrainingTake": "Partecipa alla formazione", + "AllowToTakeTraining": "Consentire di seguire la formazione", "TrainingTitle": "Titolo", "Trainings": "Formazioni", "ViewAllTrainings": "Tutte le formazioni", diff --git a/plugins/training-assets/lang/ja.json b/plugins/training-assets/lang/ja.json index 3a962792da..ac029ab83b 100644 --- a/plugins/training-assets/lang/ja.json +++ b/plugins/training-assets/lang/ja.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "下書き", "TrainingStateReleased": "リリース済み", "TrainingTake": "トレーニングを受ける", + "AllowToTakeTraining": "トレーニングを受講できるようにする", "TrainingTitle": "タイトル", "Trainings": "トレーニング", "ViewAllTrainings": "すべてのトレーニング", diff --git a/plugins/training-assets/lang/pt-br.json b/plugins/training-assets/lang/pt-br.json index bee6e73acb..776fe19f01 100644 --- a/plugins/training-assets/lang/pt-br.json +++ b/plugins/training-assets/lang/pt-br.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Rascunho", "TrainingStateReleased": "Publicado", "TrainingTake": "Realizar treinamento", + "AllowToTakeTraining": "Permitir realizar o treinamento", "TrainingTitle": "Título", "Trainings": "Treinamentos", "ViewAllTrainings": "Todos os treinamentos", diff --git a/plugins/training-assets/lang/pt.json b/plugins/training-assets/lang/pt.json index bee6e73acb..776fe19f01 100644 --- a/plugins/training-assets/lang/pt.json +++ b/plugins/training-assets/lang/pt.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Rascunho", "TrainingStateReleased": "Publicado", "TrainingTake": "Realizar treinamento", + "AllowToTakeTraining": "Permitir realizar o treinamento", "TrainingTitle": "Título", "Trainings": "Treinamentos", "ViewAllTrainings": "Todos os treinamentos", diff --git a/plugins/training-assets/lang/ru.json b/plugins/training-assets/lang/ru.json index b6e6311861..049bca276d 100644 --- a/plugins/training-assets/lang/ru.json +++ b/plugins/training-assets/lang/ru.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Рабочая копия", "TrainingStateReleased": "Актуальный", "TrainingTake": "Пройти тренинг ещё раз", + "AllowToTakeTraining": "Разрешить проходить тренинг", "TrainingTitle": "Заголовок", "Trainings": "Тренинги", "ViewAllTrainings": "Все тренинги", diff --git a/plugins/training-assets/lang/tr.json b/plugins/training-assets/lang/tr.json index d9c46bc958..2244c50563 100644 --- a/plugins/training-assets/lang/tr.json +++ b/plugins/training-assets/lang/tr.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "Taslak", "TrainingStateReleased": "Yayınlandı", "TrainingTake": "Eğitimi al", + "AllowToTakeTraining": "Eğitimi almaya izin ver", "TrainingTitle": "Başlık", "Trainings": "Eğitimler", "ViewAllTrainings": "Tüm Eğitimler", diff --git a/plugins/training-assets/lang/zh.json b/plugins/training-assets/lang/zh.json index e2976f8b11..312e70f1ca 100644 --- a/plugins/training-assets/lang/zh.json +++ b/plugins/training-assets/lang/zh.json @@ -81,6 +81,7 @@ "TrainingStateDraft": "草稿", "TrainingStateReleased": "已发布", "TrainingTake": "参加培训", + "AllowToTakeTraining": "允许参加培训", "TrainingTitle": "标题", "Trainings": "培训", "ViewAllTrainings": "所有培训", diff --git a/plugins/training/src/index.ts b/plugins/training/src/index.ts index 9ea3d7e01c..ad4785a2b5 100644 --- a/plugins/training/src/index.ts +++ b/plugins/training/src/index.ts @@ -116,6 +116,7 @@ export default plugin(trainingId, { TrainingTake: '' as IntlString, TrainingTitle: '' as IntlString, Trainings: '' as IntlString, + AllowToTakeTraining: '' as IntlString, ViewAllTrainings: '' as IntlString, ViewAllTrainingsAll: '' as IntlString, ViewAllTrainingsArchived: '' as IntlString, diff --git a/plugins/workbench-resources/src/components/Applications.svelte b/plugins/workbench-resources/src/components/Applications.svelte index e5e419549c..463a065126 100644 --- a/plugins/workbench-resources/src/components/Applications.svelte +++ b/plugins/workbench-resources/src/components/Applications.svelte @@ -14,7 +14,7 @@ -->
- {#if loaded} + {#if loaded && permissionsLoaded} Date: Sun, 5 Apr 2026 14:37:31 +0700 Subject: [PATCH 06/10] Fix Mermaid diagram paste (#10714) Signed-off-by: Artem Savchenko --- .../extension/codeSnippets/mermaid.ts | 86 +++++- .../shortcuts/__tests__/smartPaste.test.ts | 245 ++++++++++++++++++ .../extension/shortcuts/smartPaste.ts | 16 +- 3 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts diff --git a/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts b/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts index 3d546dd98f..c075f12775 100644 --- a/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts +++ b/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts @@ -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({ 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({ } }) +/** + * 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 @@ -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(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) diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts new file mode 100644 index 0000000000..2505f94b7e --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts @@ -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) + }) +}) diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts index 0ac28afe83..598eb4aa86 100644 --- a/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts @@ -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([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 } } From 85c991ffcc1c6258149c600bf99950f6045617be Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 5 Apr 2026 14:37:52 +0700 Subject: [PATCH 07/10] Fix run-local for print service (#10719) Signed-off-by: Artem Savchenko --- common/config/rush/pnpm-lock.yaml | 3 +++ services/print/pod-print/.gitignore | 1 + services/print/pod-print/package.json | 1 + services/print/pod-print/src/config.ts | 4 ++++ services/print/pod-print/src/index.ts | 2 -- 5 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 services/print/pod-print/.gitignore 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/services/print/pod-print/.gitignore b/services/print/pod-print/.gitignore new file mode 100644 index 0000000000..2eea525d88 --- /dev/null +++ b/services/print/pod-print/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/services/print/pod-print/package.json b/services/print/pod-print/package.json index 284d87549d..69dfba57ef 100644 --- a/services/print/pod-print/package.json +++ b/services/print/pod-print/package.json @@ -46,6 +46,7 @@ "eslint-plugin-n": "^15.4.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.1.1", + "cross-env": "~7.0.3", "jest": "^29.7.0", "ts-jest": "^29.1.1", "@types/jest": "^29.5.5", diff --git a/services/print/pod-print/src/config.ts b/services/print/pod-print/src/config.ts index 0260e784f4..0c94142c8d 100644 --- a/services/print/pod-print/src/config.ts +++ b/services/print/pod-print/src/config.ts @@ -2,6 +2,10 @@ // Copyright © 2024 Hardcore Engineering Inc. // +import { config as dotenvConfig } from 'dotenv' + +dotenvConfig() + export interface Config { Port: number Secret: string diff --git a/services/print/pod-print/src/index.ts b/services/print/pod-print/src/index.ts index e18b8ddb2a..030b3747b2 100644 --- a/services/print/pod-print/src/index.ts +++ b/services/print/pod-print/src/index.ts @@ -14,8 +14,6 @@ // limitations under the License. // -import { config } from 'dotenv' import { main } from './main' -config() void main() From d78d9daba5601cc1df5f943290b9fc3a099aad1f Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 5 Apr 2026 14:44:22 +0700 Subject: [PATCH 08/10] feat: Support find in desktop client (#10723) Signed-off-by: Artem Savchenko --- desktop/src/__test__/main/config.test.ts | 4 +- desktop/src/__test__/main/findInPage.test.ts | 134 ++++++ desktop/src/__test__/main/path.test.ts | 45 ++ desktop/src/main/args.ts | 1 + desktop/src/main/config.ts | 20 +- desktop/src/main/customMenu.ts | 6 +- desktop/src/main/findInPage.ts | 207 +++++++++ desktop/src/main/findInPageOverlayHost.ts | 110 +++++ desktop/src/main/path.ts | 8 +- desktop/src/main/standardMenu.ts | 14 +- desktop/src/main/start.ts | 14 + desktop/src/ui/find-in-page-overlay.ejs | 19 + desktop/src/ui/findInPageBar.ts | 427 +++++++++++++++++++ desktop/src/ui/findInPageOverlay.ts | 25 ++ desktop/src/ui/ipcMessages.ts | 7 +- desktop/src/ui/preload.ts | 28 +- desktop/src/ui/titleBarMenu.ts | 1 + desktop/src/ui/types.ts | 25 ++ desktop/webpack.config.js | 11 +- 19 files changed, 1093 insertions(+), 13 deletions(-) create mode 100644 desktop/src/__test__/main/findInPage.test.ts create mode 100644 desktop/src/__test__/main/path.test.ts create mode 100644 desktop/src/main/findInPage.ts create mode 100644 desktop/src/main/findInPageOverlayHost.ts create mode 100644 desktop/src/ui/find-in-page-overlay.ejs create mode 100644 desktop/src/ui/findInPageBar.ts create mode 100644 desktop/src/ui/findInPageOverlay.ts 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' From 7213711115421b19358ce7189bd8ad69e19fbfd0 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 5 Apr 2026 14:44:38 +0700 Subject: [PATCH 09/10] qfix: Fix card warnings (#10725) Signed-off-by: Artem Savchenko --- .../card-resources/src/components/CardAttributeEditor.svelte | 3 +-- plugins/card-resources/src/components/EditCardNew.svelte | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/card-resources/src/components/CardAttributeEditor.svelte b/plugins/card-resources/src/components/CardAttributeEditor.svelte index 45378048fd..b5c7766d56 100644 --- a/plugins/card-resources/src/components/CardAttributeEditor.svelte +++ b/plugins/card-resources/src/components/CardAttributeEditor.svelte @@ -16,8 +16,7 @@ import { Card, MasterTag } from '@hcengineering/card' import { Doc, Mixin } from '@hcengineering/core' import { getClient } from '@hcengineering/presentation' - import { Button, Grid, IconDownOutline, IconUpOutline, Switcher, resizeObserver } from '@hcengineering/ui' - import { onMount } from 'svelte' + import { Button, Grid, IconDownOutline, IconUpOutline, resizeObserver } from '@hcengineering/ui' import card from '../plugin' import MasterTagAttributes from './MasterTagAttributes.svelte' import TagAttributes from './TagAttributes.svelte' diff --git a/plugins/card-resources/src/components/EditCardNew.svelte b/plugins/card-resources/src/components/EditCardNew.svelte index d446741339..19b6b8477b 100644 --- a/plugins/card-resources/src/components/EditCardNew.svelte +++ b/plugins/card-resources/src/components/EditCardNew.svelte @@ -36,7 +36,6 @@ eventToHTMLElement, FocusHandler, getCurrentLocation, - Icon, IconDetailsFilled, IconMaxWidth, IconMoreH, @@ -49,7 +48,7 @@ import { canChangeDoc, showMenu } from '@hcengineering/view-resources' import { permissionsStore } from '@hcengineering/contact-resources' - import { afterUpdate, getContext, setContext } from 'svelte' + import { afterUpdate } from 'svelte' import card from '../plugin' import { openCardInSidebar, setViewMode, viewStore } from '../utils' import CardIcon from './CardIcon.svelte' From ff2adeb8f25c7a31b380922674a32c80a501cd9d Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 6 Apr 2026 13:21:31 +0700 Subject: [PATCH 10/10] Remove mobile not supported placeholder (#10718) Signed-off-by: Artem Savchenko --- plugins/workbench-assets/lang/cs.json | 2 - plugins/workbench-assets/lang/de.json | 2 - plugins/workbench-assets/lang/en.json | 2 - plugins/workbench-assets/lang/es.json | 2 - plugins/workbench-assets/lang/fr.json | 2 - plugins/workbench-assets/lang/it.json | 2 - plugins/workbench-assets/lang/ja.json | 2 - plugins/workbench-assets/lang/pt-br.json | 2 - plugins/workbench-assets/lang/pt.json | 2 - plugins/workbench-assets/lang/ru.json | 2 - plugins/workbench-assets/lang/tr.json | 2 - plugins/workbench-assets/lang/zh.json | 2 - .../src/components/WorkbenchApp.svelte | 138 +++++++----------- plugins/workbench-resources/src/plugin.ts | 7 +- 14 files changed, 57 insertions(+), 112 deletions(-) diff --git a/plugins/workbench-assets/lang/cs.json b/plugins/workbench-assets/lang/cs.json index b1d5fbe6db..0743c4bd7f 100644 --- a/plugins/workbench-assets/lang/cs.json +++ b/plugins/workbench-assets/lang/cs.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Je k dispozici nová verze", "PleaseUpdate": "Aktualizujte prosím", "ServerUnderMaintenance": "Příprava pracovního prostoru na novou verzi...", - "MobileNotSupported": "Omlouváme se, podpora mobilních zařízení bude brzy dostupná. Zatím prosím použijte počítač", - "LogInAnyway": "Přihlásit se přesto", "WorkspaceCreating": "Vytváření probíhá...", "AccessDenied": "Objekt neexistuje nebo k němu nemáte oprávnění.", "UpgradeDownloadProgress": "Stahování aktualizace: {percent}%", diff --git a/plugins/workbench-assets/lang/de.json b/plugins/workbench-assets/lang/de.json index 1c52cdc095..c0293a7cc9 100644 --- a/plugins/workbench-assets/lang/de.json +++ b/plugins/workbench-assets/lang/de.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Neue Version verfügbar", "PleaseUpdate": "Bitte aktualisieren", "ServerUnderMaintenance": "Workspace wird für neue Version vorbereitet...", - "MobileNotSupported": "Entschuldigung, Mobile-Unterstützung kommt in Kürze. Bitte nutzen Sie vorerst Desktop", - "LogInAnyway": "Trotzdem einloggen", "WorkspaceCreating": "Erstellung läuft...", "AccessDenied": "Objekt existiert nicht oder Sie haben keine Zugriffsberechtigung.", "UpgradeDownloadProgress": "Download des Updates: {percent}%", diff --git a/plugins/workbench-assets/lang/en.json b/plugins/workbench-assets/lang/en.json index fbbeeb9b6a..92be0bc189 100644 --- a/plugins/workbench-assets/lang/en.json +++ b/plugins/workbench-assets/lang/en.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "New version is available", "PleaseUpdate": "Please update", "ServerUnderMaintenance": "Preparing workspace for new version...", - "MobileNotSupported": "Sorry, mobile devices support coming soon. In the meantime, please use Desktop", - "LogInAnyway": "Log in anyway", "WorkspaceCreating": "Creation in progress...", "AccessDenied": "Object doesn't exist or you are not permitted to access it.", "UpgradeDownloadProgress": "Downloading upgrade: {percent}%", diff --git a/plugins/workbench-assets/lang/es.json b/plugins/workbench-assets/lang/es.json index 8b116d0229..e2ae52da25 100644 --- a/plugins/workbench-assets/lang/es.json +++ b/plugins/workbench-assets/lang/es.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Nueva versión disponible", "PleaseUpdate": "Por favor, actualice", "ServerUnderMaintenance": "Preparando el espacio de trabajo para la nueva versión...", - "MobileNotSupported": "Disculpa, el soporte para dispositivos móviles estará disponible próximamente. Mientras tanto, por favor usa el escritorio.", - "LogInAnyway": "Iniciar sesión de todas formas", "WorkspaceCreating": "Creation in progress...", "AccessDenied": "El objeto no existe o no tienes permiso para acceder a él.", "UpgradeDownloadProgress": "Descargando actualización: {percent}%", diff --git a/plugins/workbench-assets/lang/fr.json b/plugins/workbench-assets/lang/fr.json index 511274d160..88c675829b 100644 --- a/plugins/workbench-assets/lang/fr.json +++ b/plugins/workbench-assets/lang/fr.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Nouvelle version disponible", "PleaseUpdate": "Veuillez mettre à jour", "ServerUnderMaintenance": "Préparation de l'espace de travail pour la nouvelle version...", - "MobileNotSupported": "Désolé, le support pour les appareils mobiles arrive bientôt. En attendant, veuillez utiliser un ordinateur de bureau", - "LogInAnyway": "Se connecter quand même", "WorkspaceCreating": "Création en cours...", "AccessDenied": "L'objet n'existe pas ou vous n'êtes pas autorisé à y accéder.", "UpgradeDownloadProgress": "Téléchargement de la mise à jour: {percent}%", diff --git a/plugins/workbench-assets/lang/it.json b/plugins/workbench-assets/lang/it.json index df5e1b6a3b..a87713aba7 100644 --- a/plugins/workbench-assets/lang/it.json +++ b/plugins/workbench-assets/lang/it.json @@ -26,8 +26,6 @@ "NewVersionAvailable": "Nuova versione disponibile", "PleaseUpdate": "Si prega di aggiornare", "ServerUnderMaintenance": "Il server è in manutenzione", - "MobileNotSupported": "Spiacenti, il supporto per i dispositivi mobili arriverà presto. Nel frattempo, si prega di utilizzare un computer desktop", - "LogInAnyway": "Accedi comunque", "WorkspaceCreating": "Creazione in corso...", "AccessDenied": "L'oggetto non esiste o non hai autorizzazione per accedervi.", "WorkspaceIsMigrating": "Il workspace è in fase di aggiornamento. Attendi..." diff --git a/plugins/workbench-assets/lang/ja.json b/plugins/workbench-assets/lang/ja.json index 4dbb329a0d..35353ee968 100644 --- a/plugins/workbench-assets/lang/ja.json +++ b/plugins/workbench-assets/lang/ja.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "新しいバージョンが利用可能です", "PleaseUpdate": "アップデートしてください", "ServerUnderMaintenance": "新しいバージョンのためにワークスペースを準備中...", - "MobileNotSupported": "申し訳ありませんが、モバイルデバイスのサポートは近日公開予定です。それまでの間、パソコンを使用してください", - "LogInAnyway": "無視してログインする", "WorkspaceCreating": "作成中...", "AccessDenied": "オブジェクトが存在しないか、アクセス権がありません。", "UpgradeDownloadProgress": "アップグレードをダウンロード中: {percent}%", diff --git a/plugins/workbench-assets/lang/pt-br.json b/plugins/workbench-assets/lang/pt-br.json index 1d955ca988..c3bdad36a9 100644 --- a/plugins/workbench-assets/lang/pt-br.json +++ b/plugins/workbench-assets/lang/pt-br.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Nova versão disponível", "PleaseUpdate": "Atualize", "ServerUnderMaintenance": "Preparando o espaço de trabalho para a nova versão...", - "MobileNotSupported": "Desculpe, o suporte para dispositivos móveis estará disponível em breve. Enquanto isso, por favor, use o Desktop.", - "LogInAnyway": "Entrar de qualquer maneira", "WorkspaceCreating": "Creation in progress...", "AccessDenied": "O objeto não existe ou você não tem permissão para acessá-lo.", "UpgradeDownloadProgress": "Baixando atualização: {percent}%", diff --git a/plugins/workbench-assets/lang/pt.json b/plugins/workbench-assets/lang/pt.json index cbdc1b0b4b..0e02479cf6 100644 --- a/plugins/workbench-assets/lang/pt.json +++ b/plugins/workbench-assets/lang/pt.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Nova versão disponível", "PleaseUpdate": "Atualize", "ServerUnderMaintenance": "Preparando o espaço de trabalho para a nova versão...", - "MobileNotSupported": "Desculpe, o suporte para dispositivos móveis estará disponível em breve. Enquanto isso, por favor, use o Desktop.", - "LogInAnyway": "Entrar de qualquer maneira", "WorkspaceCreating": "Creation in progress...", "AccessDenied": "O objeto não existe ou você não tem permissão para acessá-lo.", "UpgradeDownloadProgress": "Baixando atualização: {percent}%", diff --git a/plugins/workbench-assets/lang/ru.json b/plugins/workbench-assets/lang/ru.json index 1e3a0dd11d..ede8abbac3 100644 --- a/plugins/workbench-assets/lang/ru.json +++ b/plugins/workbench-assets/lang/ru.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Доступна новая версия", "PleaseUpdate": "Пожалуйста, обновите приложение", "ServerUnderMaintenance": "Подготовка рабочего пространства для новой версии...", - "MobileNotSupported": "Простите, поддержка мобильных устройств скоро будет доступна. Пока воспользуйтесь компьютером.", - "LogInAnyway": "Все равно войти", "WorkspaceCreating": "Пространство создается...", "AccessDenied": "Объект не существует или у вас нет прав доступа.", "UpgradeDownloadProgress": "Загрузка обновления: {percent}%", diff --git a/plugins/workbench-assets/lang/tr.json b/plugins/workbench-assets/lang/tr.json index 55c89fba4c..5386531f30 100644 --- a/plugins/workbench-assets/lang/tr.json +++ b/plugins/workbench-assets/lang/tr.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "Yeni sürüm mevcut", "PleaseUpdate": "Lütfen güncelleyin", "ServerUnderMaintenance": "Çalışma alanı yeni sürüm için hazırlanıyor...", - "MobileNotSupported": "Üzgünüz, mobil cihaz desteği yakında geliyor. Bu arada lütfen Masaüstü kullanın", - "LogInAnyway": "Yine de giriş yap", "WorkspaceCreating": "Oluşturma devam ediyor...", "AccessDenied": "Nesne mevcut değil veya erişim izniniz yok.", "UpgradeDownloadProgress": "Güncelleme indiriliyor: {percent}%", diff --git a/plugins/workbench-assets/lang/zh.json b/plugins/workbench-assets/lang/zh.json index 7e56811532..ded7344b6c 100644 --- a/plugins/workbench-assets/lang/zh.json +++ b/plugins/workbench-assets/lang/zh.json @@ -25,8 +25,6 @@ "NewVersionAvailable": "有新版本可用", "PleaseUpdate": "请更新", "ServerUnderMaintenance": "正在为新版本准备工作区...", - "MobileNotSupported": "抱歉,移动设备支持即将推出。在此期间,请使用桌面设备", - "LogInAnyway": "仍然登录", "WorkspaceCreating": "创建进行中...", "AccessDenied": "对象不存在或您无权访问", "UpgradeDownloadProgress": "正在下载更新:{percent}%", diff --git a/plugins/workbench-resources/src/components/WorkbenchApp.svelte b/plugins/workbench-resources/src/components/WorkbenchApp.svelte index dd167fa7be..ac07818d06 100644 --- a/plugins/workbench-resources/src/components/WorkbenchApp.svelte +++ b/plugins/workbench-resources/src/components/WorkbenchApp.svelte @@ -15,17 +15,7 @@ {#if $location.path[0] === workbenchId || $location.path[0] === workbenchRes.component.WorkbenchApp} - {#if $deviceOptionsStore.isMobile && mobileAllowed !== true} -
-
-

-
-
- {:else} - {#key $location.path[1]} - {#await connect(getMetadata(workbenchRes.metadata.PlatformTitle) ?? 'Platform')} - - {#if ($workspaceCreating ?? -1) >= 0} -
-
- {/if} - {#if $error} -
- {$error} -
- {/if} - {#if $upgradeDownloadProgress >= 0} -
-
- {/if} - - {#if $error && $errorActions.length > 0} - {#each $errorActions as action} -