feat: Add ability to configure guest permissions (#10708)

* Configure guest permissions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix permission domain

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix permission declaration

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix lock file

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Guest permissions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Allow guests to update their own documents

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add modules order

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix translations, icons

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix disabled apps and update translations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-05 14:36:59 +07:00
committed by GitHub
parent 8b5b57563e
commit ea254970c0
81 changed files with 1390 additions and 33 deletions
@@ -582,6 +582,19 @@ export interface ClassPermission extends Permission {
targetClass: Ref<Class<Doc>>
}
/**
* @public
*/
export interface ModulePermissionGroup extends Doc {
application: Ref<Doc>
role: AccountRole
permissions: Ref<Permission>[]
disabledPermissions?: Ref<Permission>[]
spaceClass: Ref<Class<Space>>
enabled: boolean
order?: number
}
/**
* @public
*/
@@ -43,6 +43,7 @@ import type {
MarkupBlobRef,
MigrationState,
Mixin,
ModulePermissionGroup,
Obj,
Permission,
PersonId,
@@ -180,7 +181,8 @@ export default plugin(coreId, {
Sequence: '' as Ref<Class<Sequence>>,
CustomSequence: '' as Ref<Class<CustomSequence>>,
ClassCollaborators: '' as Ref<Class<ClassCollaborators<Doc>>>,
Collaborator: '' as Ref<Class<Collaborator>>
Collaborator: '' as Ref<Class<Collaborator>>,
ModulePermissionGroup: '' as Ref<Class<ModulePermissionGroup>>
},
icon: {
TypeString: '' as Asset,
@@ -279,6 +281,7 @@ export default plugin(coreId, {
Account: '' as IntlString,
StatusCategory: '' as IntlString,
Rank: '' as IntlString,
Order: '' as IntlString,
Members: '' as IntlString,
Owners: '' as IntlString,
Permission: '' as IntlString,
@@ -7,10 +7,14 @@ import {
import core, {
type Account,
AccountRole,
type Class,
type Doc,
type ClassPermission,
type Permission,
hasAccountRole,
type MeasureContext,
type PersonId,
type Ref,
type SessionData,
type Space,
type Tx,
@@ -22,7 +26,15 @@ import core, {
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import contact, { type Person } from '@hcengineering/contact'
/** Cached state loaded from GuestPermissionsSettings configuration document. */
interface GuestPermissionsCache {
roleAllowedClasses: Map<AccountRole, Set<Ref<Class<Doc>>>>
}
export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware {
private permissionsCache: GuestPermissionsCache | undefined = undefined
private initPromise: Promise<void> | undefined = undefined
static async create (
ctx: MeasureContext,
context: PipelineContext,
@@ -31,9 +43,86 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
return new GuestPermissionsMiddleware(context, next)
}
private async getPermissionsCache (ctx: MeasureContext): Promise<GuestPermissionsCache> {
if (this.permissionsCache !== undefined) return this.permissionsCache
if (this.initPromise === undefined) {
this.initPromise = this.loadPermissionsCache(ctx)
}
await this.initPromise
this.initPromise = undefined
return this.permissionsCache ?? { roleAllowedClasses: new Map() }
}
private async loadPermissionsCache (ctx: MeasureContext): Promise<void> {
try {
const docs = await this.findAll(ctx, core.class.ModulePermissionGroup, {}, {})
if (docs.length > 0) {
const rolePermissions = new Map<AccountRole, Set<Ref<Permission>>>()
const allPermissionIds = new Set<Ref<Permission>>()
for (const group of docs as any[]) {
if (group.enabled === false) continue
const role = ((group.role as AccountRole | undefined) ??
(Array.isArray(group.roles) && group.roles.length > 0 ? (group.roles[0] as AccountRole) : undefined) ??
AccountRole.Guest) as AccountRole
const permissions = (group.permissions ?? []) as Ref<Permission>[]
const disabled = new Set<Ref<Permission>>((group.disabledPermissions ?? []) as Ref<Permission>[])
const current = rolePermissions.get(role) ?? new Set<Ref<Permission>>()
for (const permissionId of permissions) {
if (disabled.has(permissionId)) continue
current.add(permissionId)
allPermissionIds.add(permissionId)
}
rolePermissions.set(role, current)
}
const classPermissions =
allPermissionIds.size > 0
? await this.findAll(
ctx,
core.class.ClassPermission as Ref<Class<Doc>>,
{ _id: { $in: Array.from(allPermissionIds) } } as any
)
: []
const permissionToClass = new Map<Ref<Permission>, Ref<Class<Doc>>>(
classPermissions
.map(
(permission) => [permission._id as Ref<Permission>, (permission as ClassPermission).targetClass] as const
)
.filter((entry): entry is readonly [Ref<Permission>, Ref<Class<Doc>>] => entry[1] !== undefined)
)
const roleAllowedClasses = new Map<AccountRole, Set<Ref<Class<Doc>>>>()
for (const [role, permissions] of rolePermissions.entries()) {
const allowedClasses = new Set<Ref<Class<Doc>>>()
for (const permissionId of permissions) {
const targetClass = permissionToClass.get(permissionId)
if (targetClass !== undefined) allowedClasses.add(targetClass)
}
roleAllowedClasses.set(role, allowedClasses)
}
this.permissionsCache = { roleAllowedClasses }
} else {
this.permissionsCache = { roleAllowedClasses: new Map() }
}
} catch {
this.permissionsCache = { roleAllowedClasses: new Map() }
}
}
private invalidateCacheIfNeeded (txes: Tx[]): void {
for (const tx of txes) {
if (TxProcessor.isExtendsCUD(tx._class)) {
const cudTx = tx as TxCUD<Doc>
if (cudTx.objectClass === core.class.ModulePermissionGroup) {
this.permissionsCache = undefined
return
}
}
}
}
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
const account = ctx.contextData.account
if (hasAccountRole(account, AccountRole.User)) {
this.invalidateCacheIfNeeded(txes)
return await this.provideTx(ctx, txes)
}
@@ -71,9 +160,64 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
}
}
/**
* Returns the covered-class ancestor of the objectClass if one exists in the new permissions model,
* or undefined if the class is not covered.
*/
private getCoveredClass (
objectClass: Ref<Class<Doc>>,
allowedClasses: Set<Ref<Class<Doc>>>
): Ref<Class<Doc>> | undefined {
if (allowedClasses.size === 0) return undefined
const h = this.context.hierarchy
for (const coveredClass of allowedClasses) {
if (h.isDerived(objectClass, coveredClass)) {
return coveredClass
}
}
return undefined
}
private isCreatedByAccount (doc: Doc, account: Account): boolean {
const creator = doc.createdBy
if (creator === undefined) return false
if (creator === account.primarySocialId) return true
return account.socialIds.includes(creator)
}
private async isGuestMutationOnOwnDoc (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class !== core.class.TxUpdateDoc && tx._class !== core.class.TxRemoveDoc) return false
const docs = await this.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 })
const doc = docs[0] as Doc | undefined
if (doc === undefined) return false
return this.isCreatedByAccount(doc, account)
}
private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class === core.class.TxMixin) return false
return !(await this.hasMixinAccessLevel(ctx, tx, account))
// For TxCreateDoc, check the new permission model first for covered types.
if (tx._class === core.class.TxCreateDoc) {
const cache = await this.getPermissionsCache(ctx)
const roleAllowedClasses = cache.roleAllowedClasses.get(account.role) ?? new Set<Ref<Class<Doc>>>()
const coveredClass = this.getCoveredClass(tx.objectClass, roleAllowedClasses)
if (coveredClass !== undefined) {
return false
}
// Uncovered class: fall through to TxAccessLevel check.
}
if (await this.hasMixinAccessLevel(ctx, tx, account)) {
return false
}
if (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxRemoveDoc) {
if (await this.isGuestMutationOnOwnDoc(ctx, tx, account)) {
return false
}
}
return true
}
private async isForbiddenSpaceTx (ctx: MeasureContext, tx: TxCUD<Space>, account: Account): Promise<boolean> {
@@ -0,0 +1,491 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
/**
* Tests for GuestPermissionsMiddleware
*
* Verifies that:
* - Non-guest users pass through without restriction.
* - DocGuest / ReadOnlyGuest users are always forbidden.
* - For covered classes (resolved from module allowedPermissions):
* new permission model is authoritative; TxAccessLevel is ignored.
* Create in any space → permitted.
* - For uncovered classes: TxAccessLevel fallback is used.
*/
import core, {
AccountRole,
generateId,
Hierarchy,
MeasureMetricsContext,
type Account,
type Class,
type Doc,
type MeasureContext,
type PersonId,
type Ref,
type SessionData,
type Space,
type Tx,
TxFactory
} from '@hcengineering/core'
import type { PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core'
import { GuestPermissionsMiddleware } from '../guestPermissions'
const COVERED_CLASS = 'test:class:CoveredClass' as Ref<Class<Doc>>
const UNCOVERED_CLASS = 'test:class:UncoveredClass' as Ref<Class<Doc>>
const COVERED_CLASS_PERMISSION = 'test:permission:CoveredClassPermission' as Ref<Doc>
const MODULE_PERMISSION_GROUP_CLASS = core.class.ModulePermissionGroup
const ALLOWED_SPACE = 'test:space:Allowed' as Ref<Space>
const FORBIDDEN_SPACE = 'test:space:Forbidden' as Ref<Space>
function makeAccount (role: AccountRole): Account {
return {
uuid: generateId() as any,
role,
primarySocialId: 'test' as PersonId,
socialIds: ['test' as PersonId],
fullSocialIds: []
}
}
function makeCtx (account: Account): MeasureContext<SessionData> {
const ctx = new MeasureMetricsContext('test', {}) as MeasureContext<SessionData>
ctx.contextData = {
account,
broadcast: { txes: [], queue: [], sessions: {} }
} as any
return ctx
}
type FindAllFn = (ctx: MeasureContext, _class: Ref<Class<Doc>>, query: object, options?: object) => Promise<Doc[]>
function makePipelineContext (findAll?: FindAllFn): PipelineContext {
const hierarchy = new Hierarchy()
const model = { findAllSync: (_class: any, _query: any) => [] } as any
return {
workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any },
hierarchy,
modelDb: model,
branding: null as any,
adapterManager: {} as any,
storageAdapter: {} as any,
contextVars: {},
lastTx: '',
lastHash: '',
broadcastEvent: async () => {}
} as any
}
function makeMiddleware (
findAll: FindAllFn,
nextFn?: (ctx: MeasureContext, txes: Tx[]) => Promise<TxMiddlewareResult>
): GuestPermissionsMiddleware {
const context = makePipelineContext(findAll)
const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_ctx: MeasureContext, _txes: Tx[]) => ({}) }
const mw = new (GuestPermissionsMiddleware as any)(context, next)
// Override findAll to inject our test data
mw.findAll = findAll
return mw
}
function makeCreateTx (objectClass: Ref<Class<Doc>>, objectSpace: Ref<Space>): Tx {
const factory = new TxFactory('test:account:System' as PersonId)
return factory.createTxCreateDoc(objectClass, objectSpace, {})
}
// Helper: buildGuestSettings - simulate the document that loadPermissionsCache would find
function makeGuestSettingsDoc (allowedPermissions: Ref<Doc>[], disabledPermissions?: Ref<Doc>[]): Doc {
return {
_id: generateId(),
_class: MODULE_PERMISSION_GROUP_CLASS,
space: 'core:space:Workspace' as Ref<Space>,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
application: 'test:app:tracker' as Ref<Doc>,
role: AccountRole.Guest,
permissions: allowedPermissions,
...(disabledPermissions !== undefined && disabledPermissions.length > 0 ? { disabledPermissions } : {}),
spaceClass: 'core:class:Space' as Ref<Class<Doc>>,
enabled: true
} as any
}
describe('GuestPermissionsMiddleware', () => {
// ─── Non-guest users pass through ───────────────────────────────────────────
describe('non-guest users', () => {
it('User role: passes through without restriction', async () => {
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async (ctx, txes) => {
nextCalled = true
return {}
}
)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.User))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('Owner role: passes through without restriction', async () => {
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async () => {
nextCalled = true
return {}
}
)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Owner))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
})
// ─── DocGuest / ReadOnlyGuest are always forbidden ──────────────────────────
describe('DocGuest and ReadOnlyGuest', () => {
it('DocGuest: throws Forbidden for any tx', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.DocGuest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
it('ReadOnlyGuest: throws Forbidden for any tx', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.ReadOnlyGuest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
})
// ─── New permission model (covered class) ───────────────────────────────────
describe('covered class new permission model', () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
const findAllWithSettings: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
function patchHierarchy (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
}
it('allows create for covered class in any space (TxAccessLevel is irrelevant)', async () => {
let nextCalled = false
const mw = makeMiddleware(findAllWithSettings, async () => {
nextCalled = true
return {}
})
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('also allows create in another space when class is covered', async () => {
let nextCalled = false
const mw = makeMiddleware(findAllWithSettings, async () => {
nextCalled = true
return {}
})
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('ignores permissions listed in disabledPermissions (falls back to TxAccessLevel)', async () => {
const docWithDisabled = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION], [COVERED_CLASS_PERMISSION])
const findAll: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [docWithDisabled]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
const mw = makeMiddleware(findAll)
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
})
// ─── Uncovered class falls back to TxAccessLevel ────────────────────────────
describe('uncovered class TxAccessLevel fallback', () => {
it('forbids create when class has no TxAccessLevel mixin and no GuestPermissionsSettings', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
it('allows create when TxAccessLevel.createAccessLevel === Guest (uncovered type)', async () => {
// Settings exist but UNCOVERED_CLASS is NOT in allowedPermissions-derived classes
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
let nextCalled = false
const mw = makeMiddleware(
async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
},
async () => {
nextCalled = true
return {}
}
)
// Simulate TxAccessLevel mixin via hierarchy mock on the middleware context
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
if (_class === UNCOVERED_CLASS) {
return { createAccessLevel: AccountRole.Guest }
}
return undefined
}
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
})
// ─── Precedence: covered class ignores TxAccessLevel even if it would deny ──
describe('precedence new model overrides TxAccessLevel for covered types', () => {
it('allows covered class create in allowed space regardless of missing TxAccessLevel', async () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
let nextCalled = false
const mw = makeMiddleware(
async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
},
async () => {
nextCalled = true
return {}
}
)
// Ensure hierarchy says TxAccessLevel is absent for the covered class
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => undefined
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('allows covered class create in any space even if TxAccessLevel would deny', async () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
const mw = makeMiddleware(async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
})
// TxAccessLevel would allow (createAccessLevel === Guest) should be ignored
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
if (_class === COVERED_CLASS) return { createAccessLevel: AccountRole.Guest }
return undefined
}
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
})
})
// ─── Own-document mutations for guests ───────────────────────────────────────
describe('guest update/remove own documents', () => {
const GUEST_SOCIAL = 'test:guest-social' as PersonId
function makeGuestAccountWithSocial (): Account {
return {
uuid: generateId() as any,
role: AccountRole.Guest,
primarySocialId: GUEST_SOCIAL,
socialIds: [GUEST_SOCIAL],
fullSocialIds: []
}
}
function patchHierarchyNoTxAccessLevel (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
}
it('allows guest to update document created by same account', async () => {
const objectId = generateId()
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: GUEST_SOCIAL,
createdBy: GUEST_SOCIAL
} as any
]
}
return []
}
let nextCalled = false
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
expect(nextCalled).toBe(true)
})
it('allows guest to remove document created by same account', async () => {
const objectId = generateId()
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: GUEST_SOCIAL,
createdBy: GUEST_SOCIAL
} as any
]
}
return []
}
let nextCalled = false
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxRemoveDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId)
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
expect(nextCalled).toBe(true)
})
it('forbids guest to update document created by another account', async () => {
const objectId = generateId()
const otherSocial = 'test:other-social' as PersonId
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: otherSocial,
createdBy: otherSocial
} as any
]
}
return []
}
const mw = makeMiddleware(findAll)
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
await expect(mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])).rejects.toThrow()
})
})
// ─── Cache invalidation ──────────────────────────────────────────────────────
describe('cache invalidation', () => {
it('invalidates cache when GuestPermissionsSettings is updated', async () => {
const findAll: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) {
return [makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])]
}
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
const mw = makeMiddleware(findAll)
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
// First tx as guest should load cache
const userCtx = makeCtx(makeAccount(AccountRole.User))
const settingsTx: Tx = {
_id: generateId(),
_class: core.class.TxCreateDoc,
space: core.space.Tx,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
objectId: generateId(),
objectClass: MODULE_PERMISSION_GROUP_CLASS,
objectSpace: 'core:space:Workspace' as Ref<Space>
} as any
// Owner updates settings should invalidate cache
await mw.tx(userCtx, [settingsTx])
// Cache should be cleared after settings update
expect((mw as any).permissionsCache).toBeUndefined()
})
})
})
+25
View File
@@ -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']
+14
View File
@@ -69,6 +69,20 @@ export function createModel (builder: Builder): void {
chunter.app.Chunter
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: chunter.app.Chunter,
role: AccountRole.Guest,
permissions: [],
spaceClass: chunter.class.ChunterSpace,
enabled: true,
order: 30
},
chunter.ids.ModulePermissionGroup
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
+2 -1
View File
@@ -94,7 +94,8 @@ export default mergeIds(chunterId, chunter, {
Channels: '' as Ref<Viewlet>
},
ids: {
ChunterNotificationGroup: '' as Ref<NotificationGroup>
ChunterNotificationGroup: '' as Ref<NotificationGroup>,
ModulePermissionGroup: '' as Ref<Doc>
},
space: {
General: '' as Ref<Channel>,
+13
View File
@@ -1111,6 +1111,19 @@ export function createModel (builder: Builder): void {
createPrintAction(documents.class.Document, documents.action.Print)
defineSpaceType(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: documents.app.Documents,
role: AccountRole.Guest,
permissions: [],
spaceClass: documents.class.OrgSpace,
enabled: true,
order: 42
},
documents.ids.ModulePermissionGroup
)
definePermissions(builder)
defineNotifications(builder)
defineSearch(builder)
@@ -91,5 +91,8 @@ export default mergeIds(documentsId, documents, {
DocumentsNotificationGroup: '' as Ref<NotificationGroup>,
ContentNotification: '' as Ref<NotificationType>,
StateNotification: '' as Ref<NotificationType>
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
}
})
+2
View File
@@ -80,6 +80,7 @@ import {
import { definePermissions } from './permissions'
import {
TAttributePermission,
TModulePermissionGroup,
TClassPermission,
TPermission,
TRole,
@@ -136,6 +137,7 @@ export function createModel (builder: Builder): void {
TSpaceTypeDescriptor,
TRole,
TPermission,
TModulePermissionGroup,
TAttributePermission,
TClassPermission,
TAttribute,
+26
View File
@@ -17,6 +17,7 @@ import {
DOMAIN_MODEL,
DOMAIN_SPACE,
IndexKind,
type ModulePermissionGroup,
type AccountRole,
type AccountUuid,
type AnyAttribute,
@@ -46,6 +47,7 @@ import {
Prop,
TypeAccountUuid,
TypeBoolean,
TypeNumber,
TypeRef,
TypeString,
UX
@@ -193,3 +195,27 @@ export class TTxAccessLevel extends TClass implements TxAccessLevel {
updateAccessLevel?: AccountRole
isIdentity?: boolean
}
@Model(core.class.ModulePermissionGroup, core.class.Doc, DOMAIN_MODEL)
export class TModulePermissionGroup extends TDoc implements ModulePermissionGroup {
@Prop(TypeRef(core.class.Doc), core.string.AttachedTo)
application!: Ref<Doc>
@Prop(TypeString(), core.string.Roles)
role!: AccountRole
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
permissions!: Ref<Permission>[]
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
disabledPermissions?: Ref<Permission>[]
@Prop(TypeRef(core.class.Class), core.string.Class)
spaceClass!: Ref<Class<Space>>
@Prop(TypeBoolean(), core.string.Name)
enabled!: boolean
@Prop(TypeNumber(), core.string.Order)
order?: number
}
+13
View File
@@ -540,6 +540,19 @@ export function createModel (builder: Builder): void {
defineDocument(builder)
defineApplication(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: document.app.Documents,
role: AccountRole.Guest,
permissions: [],
spaceClass: document.class.Teamspace,
enabled: true,
order: 40
},
document.ids.ModulePermissionGroup
)
definePermissions(builder)
builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, {
+3
View File
@@ -61,6 +61,9 @@ export default mergeIds(documentId, document, {
Document: '' as Ref<ActionCategory>,
Other: '' as Ref<TagCategory>
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
},
string: {
ConfigDescription: '' as IntlString,
ParentDocument: '' as IntlString,
+13
View File
@@ -842,5 +842,18 @@ export function createModel (builder: Builder): void {
defineFile(builder)
defineFileVersion(builder)
defineApplication(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: drive.app.Drive,
role: AccountRole.Guest,
permissions: [],
spaceClass: drive.class.Drive,
enabled: false,
order: 60
},
drive.ids.ModulePermissionGroup
)
definePermissions(builder)
}
+3
View File
@@ -101,6 +101,9 @@ export default mergeIds(driveId, drive, {
RenameFolder: '' as ViewAction,
RestoreFileVersion: '' as ViewAction
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
},
string: {
Grid: '' as IntlString,
Name: '' as IntlString,
+14
View File
@@ -266,6 +266,20 @@ export function createModel (builder: Builder): void {
love.app.Love
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: love.app.Love,
role: AccountRole.Guest,
permissions: [],
spaceClass: love.class.Office,
enabled: true,
order: 50
},
love.ids.ModulePermissionGroup
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
+2 -1
View File
@@ -50,7 +50,8 @@ export default mergeIds(loveId, love, {
ids: {
Settings: '' as Ref<Doc>,
LoveNotificationGroup: '' as Ref<NotificationGroup>,
MeetingMinutesChatNotification: '' as Ref<NotificationType>
MeetingMinutesChatNotification: '' as Ref<NotificationType>,
ModulePermissionGroup: '' as Ref<Doc>
},
function: {
MeetingMinutesTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
+14
View File
@@ -312,6 +312,19 @@ export function createModel (builder: Builder): void {
},
setting.ids.Owners
)
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
{
name: 'guestPermissions',
label: setting.string.GuestPermissionsSettings,
icon: setting.icon.Members,
component: setting.component.GuestPermissionsSettings,
role: AccountRole.Owner,
order: 1050
},
'setting:ids:AccountPermissionsSettings' as Ref<any>
)
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
@@ -426,6 +439,7 @@ export function createModel (builder: Builder): void {
},
setting.ids.OfficeSettings
)
// Currently remove Support item from settings
// builder.createDoc(
// setting.class.SettingsCategory,
+14 -1
View File
@@ -16,7 +16,7 @@
import activity from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import core from '@hcengineering/model-core'
import { SortingOrder, type FindOptions } from '@hcengineering/core'
import { AccountRole, SortingOrder, type FindOptions } from '@hcengineering/core'
import { type Builder } from '@hcengineering/model'
import view, { createAction } from '@hcengineering/model-view'
@@ -202,6 +202,19 @@ export function createModel (builder: Builder): void {
definePresenters(builder)
defineApplication(builder)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: testManagement.app.TestManagement,
role: AccountRole.Guest,
permissions: [],
spaceClass: testManagement.class.TestProject,
enabled: false,
order: 70
},
testManagement.ids.ModulePermissionGroup
)
builder.mixin(testManagement.class.TestCase, core.class.Class, view.mixin.ObjectIcon, {
component: testManagement.component.TestCaseStatusPresenter
+4 -1
View File
@@ -15,7 +15,7 @@
import { testManagementId } from '@hcengineering/test-management'
import testManganement from '@hcengineering/test-management-resources/src/plugin'
import type { Ref } from '@hcengineering/core'
import type { Doc, Ref } from '@hcengineering/core'
import { mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import type { ActionCategory } from '@hcengineering/view'
@@ -48,5 +48,8 @@ export default mergeIds(testManagementId, testManganement, {
TestPlanItemPresenter: '' as AnyComponent,
CreateTestRunButton: '' as AnyComponent,
RunTestPlanButton: '' as AnyComponent
},
ids: {
ModulePermissionGroup: '' as Ref<Doc>
}
})
+25
View File
@@ -657,6 +657,31 @@ export function createModel (builder: Builder): void {
order: 4000
})
builder.createDoc(
core.class.ClassPermission,
core.space.Model,
{
label: tracker.string.AllowCreatingIssues,
scope: 'space',
targetClass: tracker.class.Issue
},
tracker.ids.GuestIssueClassPermission
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: tracker.app.Tracker,
role: AccountRole.Guest,
permissions: [tracker.ids.GuestIssueClassPermission],
spaceClass: tracker.class.Project,
enabled: true,
order: 10
},
tracker.ids.ModulePermissionGroup
)
builder.createDoc(
chunter.class.ChatMessageViewlet,
core.space.Model,
+4 -1
View File
@@ -41,7 +41,8 @@ export default mergeIds(trackerId, tracker, {
ConfigDescription: '' as IntlString,
AllProjects: '' as IntlString,
MapRelatedIssues: '' as IntlString,
Extensions: '' as IntlString
Extensions: '' as IntlString,
AllowCreatingIssues: '' as IntlString
},
activity: {
StatusIcon: '' as AnyComponent,
@@ -79,6 +80,8 @@ export default mergeIds(trackerId, tracker, {
TrackerNotificationGroup: '' as Ref<NotificationGroup>,
AssigneeNotification: '' as Ref<NotificationType>,
BaseProjectType: '' as Ref<ProjectType>,
GuestIssueClassPermission: '' as Ref<Doc>,
ModulePermissionGroup: '' as Ref<Doc>,
IssueUpdatedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
IssueCreatedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
IssueRemovedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
+25
View File
@@ -898,6 +898,31 @@ function defineSettings (builder: Builder): void {
},
training.setting.Trainings
)
builder.createDoc(
core.class.ClassPermission,
core.space.Model,
{
label: training.string.AllowToTakeTraining,
scope: 'space',
targetClass: training.class.TrainingAttempt
},
training.ids.GuestTrainingAttemptClassPermission
)
builder.createDoc(
core.class.ModulePermissionGroup,
core.space.Model,
{
application: training.app.Training,
role: AccountRole.Guest,
permissions: [training.ids.GuestTrainingAttemptClassPermission],
spaceClass: core.class.TypedSpace,
enabled: true,
order: 25
},
training.ids.ModulePermissionGroup
)
}
const columns = {
+4
View File
@@ -33,6 +33,10 @@ export default mergeIds(trainingId, training, {
TrainingGroup: '' as Ref<NotificationGroup>,
TrainingRequest: '' as Ref<NotificationType>
},
ids: {
GuestTrainingAttemptClassPermission: '' as Ref<Doc>,
ModulePermissionGroup: '' as Ref<Doc>
},
// TODO: Move function resources declarations to plugins/*-resources
// Currently, dependencies look like this:
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Karty",
"Content": "Obsah",
"CreateCard": "Vytvořit kartu",
"AllowCreatingCards": "Povolit vytváření karet",
"CreateMasterTag": "Vytvořit typ",
"CreateTag": "Vytvořit štítek",
"MasterTag": "Typ",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Karten",
"Content": "Inhalt",
"CreateCard": "Karte erstellen",
"AllowCreatingCards": "Erstellen von Karten erlauben",
"CreateMasterTag": "Typ erstellen",
"CreateTag": "Tag erstellen",
"MasterTag": "Typ",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Cards",
"Content": "Content",
"CreateCard": "Create Card",
"AllowCreatingCards": "Allow creating cards",
"CreateMasterTag": "Create Type",
"CreateTag": "Create Tag",
"MasterTag": "Type",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Tarjetas",
"Content": "Contenido",
"CreateCard": "Crear Tarjeta",
"AllowCreatingCards": "Permitir crear tarjetas",
"CreateMasterTag": "Crear Tipo",
"CreateTag": "Crear Etiqueta",
"MasterTag": "Tipo",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Cartes",
"Content": "Contenu",
"CreateCard": "Créer une carte",
"AllowCreatingCards": "Autoriser la création de cartes",
"CreateMasterTag": "Créer un type",
"CreateTag": "Créer une étiquette",
"MasterTag": "Type",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Carte",
"Content": "Contenuto",
"CreateCard": "Crea Carta",
"AllowCreatingCards": "Consenti la creazione di carte",
"CreateMasterTag": "Crea Tipo",
"CreateTag": "Crea Tag",
"MasterTag": "Tipo",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "カード",
"Content": "コンテンツ",
"CreateCard": "カードを作成",
"AllowCreatingCards": "カードの作成を許可",
"CreateMasterTag": "タイプを作成",
"CreateTag": "タグを作成",
"MasterTag": "タイプ",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Cartões",
"Content": "Conteúdo",
"CreateCard": "Criar Cartão",
"AllowCreatingCards": "Permitir criar cartões",
"CreateMasterTag": "Criar Tipo",
"CreateTag": "Criar Tag",
"MasterTag": "Tipo",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Cartões",
"Content": "Conteúdo",
"CreateCard": "Criar Cartão",
"AllowCreatingCards": "Permitir criar cartões",
"CreateMasterTag": "Criar Tipo",
"CreateTag": "Criar Tag",
"MasterTag": "Tipo",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Карты",
"Content": "Содержание",
"CreateCard": "Создать карту",
"AllowCreatingCards": "Разрешить создание карт",
"CreateMasterTag": "Создать тип",
"CreateTag": "Создать тег",
"MasterTag": "Тип",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "Kartlar",
"Content": "İçerik",
"CreateCard": "Kart Oluştur",
"AllowCreatingCards": "Kart oluşturmaya izin ver",
"CreateMasterTag": "Tip Oluştur",
"CreateTag": "Etiket Oluştur",
"MasterTag": "Tip",
+1
View File
@@ -5,6 +5,7 @@
"Cards": "卡片",
"Content": "内容",
"CreateCard": "创建卡片",
"AllowCreatingCards": "允许创建卡片",
"CreateMasterTag": "创建类型",
"CreateTag": "创建标签",
"MasterTag": "类型",
+4 -1
View File
@@ -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<CardSection>
},
ids: {
CardWidget: '' as Ref<Doc>
CardWidget: '' as Ref<Doc>,
GuestCardClassPermission: '' as Ref<Doc>,
ModulePermissionGroup: '' as Ref<Doc>
},
component: {
LabelsPresenter: '' as AnyComponent,
+6
View File
@@ -9,6 +9,12 @@
<path d="M4.5 11C3.11929 11 2 12.1193 2 13.5C2 13.7761 1.77614 14 1.5 14C1.22386 14 1 13.7761 1 13.5C1 11.567 2.567 10 4.5 10H6.5C8.433 10 10 11.567 10 13.5C10 13.7761 9.77614 14 9.5 14C9.22386 14 9 13.7761 9 13.5C9 12.1193 7.88071 11 6.5 11H4.5Z" />
<path d="M10.5 2.99988C10.2238 2.99988 9.99995 3.22374 9.99995 3.49988C9.99995 3.77602 10.2238 3.99988 10.5 3.99988C10.7846 3.99988 11.0661 4.06066 11.3254 4.17815C11.5847 4.29564 11.8159 4.46714 12.0036 4.68119C12.1913 4.89523 12.3312 5.14688 12.4138 5.41931C12.4965 5.69174 12.52 5.97868 12.4828 6.26093C12.4457 6.54318 12.3487 6.81425 12.1984 7.05601C12.048 7.29777 11.8478 7.50465 11.6111 7.66282C11.3744 7.82098 11.1066 7.92679 10.8257 7.97316C10.5449 8.01954 10.2573 8.00541 9.98232 7.93173C9.71558 7.86026 9.44141 8.01855 9.36994 8.28528C9.29847 8.55202 9.45677 8.82618 9.7235 8.89766C10.136 9.00818 10.5673 9.02937 10.9886 8.95981C11.41 8.89025 11.8116 8.73153 12.1667 8.49429C12.5217 8.25704 12.8221 7.94672 13.0476 7.58408C13.2731 7.22144 13.4186 6.81484 13.4743 6.39146C13.53 5.96807 13.4947 5.53767 13.3708 5.12902C13.2468 4.72038 13.037 4.3429 12.7555 4.02184C12.4739 3.70078 12.127 3.44353 11.7381 3.26729C11.3491 3.09105 10.927 2.99988 10.5 2.99988Z" />
</symbol>
<symbol id="members" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.7,5.7c-1.4,0-2.7,0.5-3.5,1.5c-0.9,0.9-1.3,2.3-1.2,3.7c0.2,2.7,2.3,5.1,4.8,5.1c2.5,0,4.6-2.3,4.8-5.1C25.7,8,23.5,5.7,20.7,5.7z M18.5,8.5C18,9,17.7,9.8,17.7,10.8c0.1,2,1.6,3.3,2.9,3.3c1.3,0,2.8-1.3,2.9-3.3c0.1-1.9-1.1-3.2-2.9-3.2C19.8,7.6,19,7.9,18.5,8.5z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.4,24.1c1.1-4.2,5.2-6.3,9.3-6.3c4,0,8.2,2,9.3,6.3c0.2,0.9-0.4,2.1-1.6,2.1H13C11.7,26.3,11.2,25.1,11.4,24.1z M13.3,24.4h14.8c-0.9-3-3.9-4.7-7.4-4.7C17.2,19.7,14.1,21.4,13.3,24.4z" />
<path d="M9.6,16.2c-2.1,0-3.9-1.9-4-4.3c-0.1-1.2,0.3-2.3,1-3.1c0.8-0.8,1.8-1.2,3-1.2c1.2,0,2.2,0.4,3,1.3c0.8,0.8,1.1,1.9,1.1,3.1C13.5,14.3,11.7,16.2,9.6,16.2z M9.6,9.5C9,9.5,8.4,9.7,8,10.1c-0.4,0.4-0.6,1-0.5,1.7c0.1,1.4,1.1,2.5,2.2,2.5s2.1-1.2,2.2-2.5c0-0.7-0.2-1.3-0.6-1.7C10.9,9.7,10.3,9.5,9.6,9.5z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M2,22.5c0.9-3.5,4.3-5.2,7.6-5.2c1.3,0,2.6,0.2,3.8,0.8c0.5,0.2,0.7,0.8,0.5,1.2c-0.2,0.5-0.8,0.7-1.2,0.5c-0.9-0.4-1.9-0.6-3.1-0.6c-2.6,0-4.9,1.2-5.7,3.4H10c0.5,0,0.9,0.4,0.9,0.9s-0.4,0.9-0.9,0.9H3.5C2.4,24.4,1.8,23.3,2,22.5L2,22.5z" />
</symbol>
<symbol id="password" viewBox="0 0 16 16">
<path d="M11 6C11.5523 6 12 5.55228 12 5C12 4.44772 11.5523 4 11 4C10.4477 4 10 4.44772 10 5C10 5.55228 10.4477 6 11 6Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 1C8.01472 1 6 3.01472 6 5.5C6 5.89536 6.05111 6.27942 6.14726 6.64563L1.29289 11.5C1.10536 11.6875 1 11.9419 1 12.2071V14.5C1 14.7761 1.22386 15 1.5 15H3.79289C4.05811 15 4.31246 14.8946 4.5 14.7071L9.35437 9.85274C9.72058 9.94889 10.1046 10 10.5 10C12.9853 10 15 7.98528 15 5.5C15 3.01472 12.9853 1 10.5 1ZM7 5.5C7 3.567 8.567 2 10.5 2C12.433 2 14 3.567 14 5.5C14 7.433 12.433 9 10.5 9C10.1048 9 9.72588 8.93469 9.37284 8.81468C9.19252 8.75339 8.99304 8.79986 8.85837 8.93453L8 9.79289L7.85355 9.64645C7.65829 9.45118 7.34171 9.45118 7.14645 9.64645C6.95118 9.84171 6.95118 10.1583 7.14645 10.3536L7.29289 10.5L6.5 11.2929L5.85355 10.6464C5.65829 10.4512 5.34171 10.4512 5.14645 10.6464C4.95118 10.8417 4.95118 11.1583 5.14645 11.3536L5.79289 12L5 12.7929L4.35355 12.1464C4.15829 11.9512 3.84171 11.9512 3.64645 12.1464C3.45118 12.3417 3.45118 12.6583 3.64645 12.8536L4.29289 13.5L3.79289 14H2V12.2071L7.06547 7.14163C7.20014 7.00696 7.24661 6.80748 7.18532 6.62716C7.06531 6.27412 7 5.8952 7 5.5Z" />

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+3
View File
@@ -212,6 +212,9 @@
"OfficeDefaultSettings": "Výchozí nastavení pro jednací místnosti",
"DefaultStartWithTranscription": "Povolit přepis v nových kancelářích",
"DefaultStartWithRecording": "Povolit záznam v nových kancelářích",
"GuestPermissionsSettings": "Oprávnění hostů",
"GuestPermissionsModulePermissions": "Oprávnění modulů",
"GuestPermissionsModulePermissionsHint": "Vyberte, které moduly mohou hosté používat, a níže upravte oprávnění pro jednotlivé aplikace.",
"ImportDocumentPermission": "Importovat dokumenty",
"ImportDocumentDescription": "Umožňuje uživatelům importovat dokumenty do pracovního prostoru",
"SelectUsers": "Vybrat uživatele",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Standardeinstellungen für Besprechungsräume",
"DefaultStartWithTranscription": "Transkription in neuen Büroräumen aktivieren",
"DefaultStartWithRecording": "Aufnahme in neuen Büroräumen aktivieren",
"GuestPermissionsSettings": "Gastberechtigungen",
"GuestPermissionsModulePermissions": "Modulberechtigungen",
"GuestPermissionsModulePermissionsHint": "Wählen Sie, welche Module Gäste nutzen dürfen, und passen Sie unten die Berechtigungen für jede App an.",
"ImportDocumentPermission": "Dokumente importieren",
"ImportDocumentDescription": "Gewährt Benutzern die Möglichkeit, Dokumente in den Arbeitsbereich zu importieren",
"SelectUsers": "Benutzer auswählen",
+3
View File
@@ -215,6 +215,9 @@
"OfficeDefaultSettings": "Default settings for meeting rooms",
"DefaultStartWithTranscription": "Enable transcription in new office rooms",
"DefaultStartWithRecording": "Enable recording in new office rooms",
"GuestPermissionsSettings": "Guest permissions",
"GuestPermissionsModulePermissions": "Module permissions",
"GuestPermissionsModulePermissionsHint": "Choose which modules guests can use, then adjust permissions for each app below.",
"ImportDocumentPermission": "Import documents",
"ImportDocumentDescription": "Grants users ability to import documents into the workspace",
"SelectUsers": "Select users",
+3
View File
@@ -205,6 +205,9 @@
"OfficeDefaultSettings": "Configuración predeterminada para salas de reuniones",
"DefaultStartWithTranscription": "Habilitar transcripción en nuevas oficinas",
"DefaultStartWithRecording": "Habilitar grabación en nuevas oficinas",
"GuestPermissionsSettings": "Permisos de invitados",
"GuestPermissionsModulePermissions": "Permisos de módulos",
"GuestPermissionsModulePermissionsHint": "Elija qué módulos pueden usar los invitados y luego ajuste los permisos de cada aplicación a continuación.",
"ImportDocumentPermission": "Importar documentos",
"ImportDocumentDescription": "Otorga a los usuarios la capacidad de importar documentos al espacio de trabajo",
"SelectUsers": "Seleccionar usuarios",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Paramètres par défaut pour les salles de réunion",
"DefaultStartWithTranscription": "Activer la transcription dans les nouveaux bureaux",
"DefaultStartWithRecording": "Activer l'enregistrement dans les nouveaux bureaux",
"GuestPermissionsSettings": "Permissions des invités",
"GuestPermissionsModulePermissions": "Permissions des modules",
"GuestPermissionsModulePermissionsHint": "Choisissez les modules accessibles aux invités, puis ajustez les permissions pour chaque application ci-dessous.",
"ImportDocumentPermission": "Importer des documents",
"ImportDocumentDescription": "Accorde aux utilisateurs la possibilité d'importer des documents dans l'espace de travail",
"SelectUsers": "Sélectionner des utilisateurs",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Impostazioni predefinite per le sale riunioni",
"DefaultStartWithTranscription": "Abilita trascrizione nelle nuove stanze dell'ufficio",
"DefaultStartWithRecording": "Abilita registrazione nelle nuove stanze dell'ufficio",
"GuestPermissionsSettings": "Permessi ospite",
"GuestPermissionsModulePermissions": "Permessi dei moduli",
"GuestPermissionsModulePermissionsHint": "Scegli quali moduli possono usare gli ospiti, poi regola i permessi per ogni app qui sotto.",
"ImportDocumentPermission": "Importa documenti",
"ImportDocumentDescription": "Concede agli utenti la possibilità di importare documenti nell'area di lavoro",
"SelectUsers": "Seleziona utenti",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "会議室のデフォルト設定",
"DefaultStartWithTranscription": "新しいオフィスルームで文字起こしを有効にする",
"DefaultStartWithRecording": "新しいオフィスルームで録画を有効にする",
"GuestPermissionsSettings": "ゲストの権限",
"GuestPermissionsModulePermissions": "モジュールの権限",
"GuestPermissionsModulePermissionsHint": "ゲストが利用できるモジュールを選び、その下で各アプリの権限を調整します。",
"ImportDocumentPermission": "ドキュメントをインポート",
"ImportDocumentDescription": "ユーザーにワークスペースにドキュメントをインポートする機能を付与します",
"SelectUsers": "ユーザーを選択",
+3
View File
@@ -205,6 +205,9 @@
"OfficeDefaultSettings": "Configurações padrão para salas de reunião",
"DefaultStartWithTranscription": "Habilitar transcrição em novos escritórios",
"DefaultStartWithRecording": "Habilitar gravação em novos escritórios",
"GuestPermissionsSettings": "Permissões de convidados",
"GuestPermissionsModulePermissions": "Permissões de módulos",
"GuestPermissionsModulePermissionsHint": "Escolha quais módulos os convidados podem usar e, em seguida, ajuste as permissões de cada aplicativo abaixo.",
"ImportDocumentPermission": "Importar documentos",
"ImportDocumentDescription": "Concede aos usuários a capacidade de importar documentos para o espaço de trabalho",
"SelectUsers": "Selecionar usuários",
+3
View File
@@ -205,6 +205,9 @@
"OfficeDefaultSettings": "Configurações padrão para salas de reunião",
"DefaultStartWithTranscription": "Habilitar transcrição em novos escritórios",
"DefaultStartWithRecording": "Habilitar gravação em novos escritórios",
"GuestPermissionsSettings": "Permissões de convidados",
"GuestPermissionsModulePermissions": "Permissões de módulos",
"GuestPermissionsModulePermissionsHint": "Escolha quais módulos os convidados podem usar e, em seguida, ajuste as permissões de cada aplicação abaixo.",
"ImportDocumentPermission": "Importar documentos",
"ImportDocumentDescription": "Concede aos usuários a capacidade de importar documentos para o espaço de trabalho",
"SelectUsers": "Selecionar usuários",
+3
View File
@@ -215,6 +215,9 @@
"OfficeDefaultSettings": "Настройки по умолчанию для переговорных",
"DefaultStartWithTranscription": "Включить транскрипцию в новых комнатах",
"DefaultStartWithRecording": "Включить запись в новых комнатах",
"GuestPermissionsSettings": "Права гостей",
"GuestPermissionsModulePermissions": "Права модулей",
"GuestPermissionsModulePermissionsHint": "Выберите, какие модули доступны гостям, затем настройте права для каждого приложения ниже.",
"ImportDocumentPermission": "Импорт документов",
"ImportDocumentDescription": "Предоставляет пользователям возможность импортировать документы в рабочее пространство",
"SelectUsers": "Выбрать пользователей",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "Toplantı odaları için varsayılan ayarlar",
"DefaultStartWithTranscription": "Yeni ofis odalarında transkripsiyonu etkinleştir",
"DefaultStartWithRecording": "Yeni ofis odalarında kaydı etkinleştir",
"GuestPermissionsSettings": "Misafir izinleri",
"GuestPermissionsModulePermissions": "Modül izinleri",
"GuestPermissionsModulePermissionsHint": "Misafirlerin hangi modülleri kullanabileceğini seçin, ardından her uygulama için izinleri aşağıdan ayarlayın.",
"ImportDocumentPermission": "Belgeleri içe aktar",
"ImportDocumentDescription": "Kullanıcılara çalışma alanına belge içe aktarma yeteneği verir",
"SelectUsers": "Kullanıcıları seç",
+3
View File
@@ -214,6 +214,9 @@
"OfficeDefaultSettings": "会议室的默认设置",
"DefaultStartWithTranscription": "在新办公室启用转录",
"DefaultStartWithRecording": "在新办公室启用录制",
"GuestPermissionsSettings": "访客权限",
"GuestPermissionsModulePermissions": "模块权限",
"GuestPermissionsModulePermissionsHint": "选择访客可以使用哪些模块,然后在下方调整每个应用的权限。",
"ImportDocumentPermission": "导入文档",
"ImportDocumentDescription": "授予用户将文档导入工作区的权限",
"SelectUsers": "选择用户",
+1
View File
@@ -20,6 +20,7 @@ const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(setting.icon, {
AccountSettings: `${icons}#accountSettings`,
Owners: `${icons}#owners`,
Members: `${icons}#members`,
Password: `${icons}#password`,
Setting: `${icons}#settings`,
Integrations: `${icons}#integration`,
@@ -0,0 +1,376 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import core, { ModulePermissionGroup, type Doc, type Permission, type Ref } from '@hcengineering/core'
import { getEmbeddedLabel, getMetadata, type IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import workbench, { type Application } from '@hcengineering/workbench'
import { Breadcrumb, Header, Icon, Label, Loading, Scroller, Toggle } from '@hcengineering/ui'
import setting from '@hcengineering/setting'
let loadingSettings = true
let loadingPermissions = true
let workspaceAppsReady = false
let moduleGroups: ModulePermissionGroup[] = []
let permissionsMap: Map<Ref<Permission>, Permission> = new Map<Ref<Permission>, Permission>()
let hiddenApplicationIds: Array<Ref<Application>> = []
const excludedApplicationIds = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const client = getClient()
const moduleGroupsQuery = createQuery()
const permissionsQuery = createQuery()
const hiddenAppsQuery = createQuery()
$: moduleGroupsQuery.query(core.class.ModulePermissionGroup, {}, (res) => {
moduleGroups = res as unknown as ModulePermissionGroup[]
loadingSettings = false
})
$: permissionsQuery.query(core.class.Permission, {}, (res) => {
permissionsMap = new Map((res as Permission[]).map((permission) => [permission._id, permission]))
loadingPermissions = false
})
$: hiddenAppsQuery.query(workbench.class.HiddenApplication, { space: core.space.Workspace }, (res) => {
hiddenApplicationIds = res.map((r) => r.attachedTo)
workspaceAppsReady = true
})
/** Same notion of “available in this workspace” as the app switcher: model apps minus hidden/excluded. */
$: workspaceApplications = client
.getModel()
.findAllSync<Application>(workbench.class.Application, {
hidden: false,
_id: { $nin: excludedApplicationIds }
})
.filter((app) => !hiddenApplicationIds.includes(app._id))
$: applicationsMap = new Map<Ref<Doc>, Application>(
workspaceApplications.map((application) => [application._id as Ref<Doc>, application])
)
$: loading = loadingSettings || loadingPermissions || !workspaceAppsReady
/** Ignore permission groups for applications not enabled in this workspace. */
$: visibleModuleGroups = moduleGroups.filter((group) => applicationsMap.has(group.application))
$: sortedVisibleModuleGroups = [...visibleModuleGroups].sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
function getApplicationLabel (applicationId: Ref<Doc>): IntlString {
return applicationsMap.get(applicationId)?.label ?? getEmbeddedLabel(applicationId)
}
function getApplication (applicationId: Ref<Doc>): Application | undefined {
return applicationsMap.get(applicationId)
}
function getDisabledPermissions (group: ModulePermissionGroup): Set<Ref<Permission>> {
return new Set(group.disabledPermissions ?? [])
}
function isPermissionActive (group: ModulePermissionGroup, permissionId: Ref<Permission>): boolean {
return !getDisabledPermissions(group).has(permissionId)
}
async function togglePermission (
group: ModulePermissionGroup,
permissionId: Ref<Permission>,
enabled: boolean
): Promise<void> {
if (!isModuleEnabled(group)) return
const disabled = getDisabledPermissions(group)
if (enabled) {
disabled.delete(permissionId)
} else {
disabled.add(permissionId)
}
await client.updateDoc(core.class.ModulePermissionGroup, core.space.Model, group._id, {
disabledPermissions: Array.from(disabled)
} as any)
}
async function toggleModule (group: ModulePermissionGroup, enabled: boolean): Promise<void> {
await client.updateDoc(core.class.ModulePermissionGroup, core.space.Model, group._id, {
enabled
} as any)
}
function isModuleEnabled (group: ModulePermissionGroup): boolean {
return group.enabled ?? true
}
function getPermissionLabel (permissionId: Ref<Permission>): IntlString {
return permissionsMap.get(permissionId)?.label ?? getEmbeddedLabel(permissionId)
}
function onAccessToggle (group: ModulePermissionGroup, ev: Event): void {
const e = ev as CustomEvent<boolean>
void toggleModule(group, e.detail)
}
function onPermissionToggle (group: ModulePermissionGroup, permissionId: Ref<Permission>, ev: Event): void {
const e = ev as CustomEvent<boolean>
void togglePermission(group, permissionId, e.detail)
}
function handleAccessToggle (group: ModulePermissionGroup): (ev: Event) => void {
return (ev: Event) => {
onAccessToggle(group, ev)
}
}
function handlePermissionToggle (group: ModulePermissionGroup, permissionId: Ref<Permission>): (ev: Event) => void {
return (ev: Event) => {
onPermissionToggle(group, permissionId, ev)
}
}
</script>
<div class="hulyComponent">
<Header adaptive={'disabled'}>
<Breadcrumb label={setting.string.GuestPermissionsSettings} size={'large'} isCurrent />
</Header>
<div class="hulyComponent-content__column content">
{#if loading}
<div class="w-full h-full flex-col-center justify-center">
<Loading />
</div>
{:else}
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content guestPermissionsRoot flex-col">
<section class="section">
<div class="sectionHeader">
<div class="sectionTitle">
<Label label={setting.string.GuestPermissionsModulePermissions} />
</div>
<div class="sectionHint">
<Label label={setting.string.GuestPermissionsModulePermissionsHint} />
</div>
</div>
<div class="cardStack">
{#each sortedVisibleModuleGroups as group}
{@const app = getApplication(group.application)}
{@const moduleOn = isModuleEnabled(group)}
{@const permissionCount = (group.permissions ?? []).length}
<div class="permissionModuleCard" class:permissionModuleCard-off={!moduleOn}>
<div
class="permissionModuleCard-header"
class:permissionModuleCard-headerOnly={permissionCount === 0}
>
<div class="permissionModuleCard-headerMain">
{#if app}
<div class="appIcon appIcon-sm">
<Icon icon={app.icon} size={'small'} />
</div>
{:else}
<div class="appIcon appIcon-sm appIcon-placeholder" />
{/if}
<div class="permissionModuleCard-titles">
<div class="permissionModuleCard-name">
<Label label={getApplicationLabel(group.application)} />
</div>
</div>
</div>
<div class="permissionModuleCard-toggleCell">
<Toggle on={moduleOn} on:change={handleAccessToggle(group)} />
</div>
</div>
{#if permissionCount > 0}
<div class="permissionRows">
{#each group.permissions ?? [] as permissionId}
<div class="permissionRow">
<div class="permissionRow-label">
<Label label={getPermissionLabel(permissionId)} />
</div>
<div class="permissionRow-toggleCell">
<Toggle
disabled={!moduleOn}
on={isPermissionActive(group, permissionId)}
on:change={handlePermissionToggle(group, permissionId)}
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#if visibleModuleGroups.length === 0}
<div class="emptyState emptyState-block"></div>
{/if}
</div>
</section>
</div>
</Scroller>
{/if}
</div>
</div>
<style lang="scss">
.guestPermissionsRoot {
max-width: 40rem;
width: 100%;
margin: 0 auto;
gap: 2rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sectionTitle {
font-weight: 500;
font-size: 1rem;
color: var(--theme-content-color);
}
.sectionHint {
font-size: 0.8rem;
color: var(--theme-halfcontent-color);
}
/* Matches packages/ui Toggle width for a single aligned column */
$toggleTrackWidth: 2.25rem;
.cardStack {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 0.5rem;
}
.appIcon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--small-focus-BorderRadius);
background-color: var(--theme-button-default);
color: var(--theme-caption-color);
}
.appIcon-sm {
width: 2rem;
height: 2rem;
}
.appIcon-placeholder {
opacity: 0.35;
}
.permissionModuleCard {
display: flex;
flex-direction: column;
border-radius: var(--small-focus-BorderRadius);
border: 1px solid var(--theme-navpanel-divider);
overflow: hidden;
background-color: var(--theme-panel-color);
box-shadow: var(--theme-popup-shadow);
}
.permissionModuleCard-off .permissionRows {
opacity: 0.55;
}
.permissionModuleCard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) #{$toggleTrackWidth};
align-items: center;
column-gap: 0.75rem;
padding: 0.75rem 1rem;
background-color: var(--theme-comp-header-color);
border-bottom: 1px solid var(--theme-divider-color);
&.permissionModuleCard-headerOnly {
border-bottom: none;
}
}
.permissionModuleCard-headerMain {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.permissionModuleCard-toggleCell,
.permissionRow-toggleCell {
display: flex;
justify-content: center;
align-items: center;
width: $toggleTrackWidth;
justify-self: end;
}
.permissionModuleCard-name {
font-weight: 500;
font-size: 0.9375rem;
color: var(--theme-content-color);
}
.permissionModuleCard-meta {
font-size: 0.75rem;
color: var(--theme-halfcontent-color);
}
.permissionRows {
display: flex;
flex-direction: column;
padding: 0.25rem 1rem 0.75rem;
background-color: var(--theme-panel-color);
}
.permissionRow {
display: grid;
grid-template-columns: minmax(0, 1fr) #{$toggleTrackWidth};
align-items: center;
column-gap: 0.75rem;
padding: 0.625rem 0 0.625rem 0.25rem;
min-height: 2.5rem;
}
.permissionRow:not(:first-child) {
border-top: 1px solid var(--theme-navpanel-divider);
}
.permissionRow-label {
min-width: 0;
color: var(--theme-content-color);
}
.emptyState {
font-size: 0.875rem;
color: var(--theme-halfcontent-color);
padding: 0.25rem 0;
}
.emptyState-block {
padding: 0.5rem 0;
}
</style>
+2
View File
@@ -72,6 +72,7 @@ import EditRelation from './components/EditRelation.svelte'
import AddSocialId from './components/socialIds/AddSocialId.svelte'
import AddEmailSocialId from './components/socialIds/AddEmailSocialId.svelte'
import Mailboxes from './components/Mailboxes.svelte'
import GuestPermissionsSettings from './components/GuestPermissionsSettings.svelte'
import OfficeSettings from './components/OfficeSettings.svelte'
import BaseIntegrationState from './components/integrations/BaseIntegrationState.svelte'
import IntegrationStateRow from './components/integrations/IntegrationStateRow.svelte'
@@ -164,6 +165,7 @@ export default async (): Promise<Resources> => ({
CreateRelation,
EditRelation,
Mailboxes,
GuestPermissionsSettings,
OfficeSettings,
AddSocialId,
AddEmailSocialId,
+2 -1
View File
@@ -30,7 +30,8 @@ export default mergeIds(settingId, setting, {
ManageSpaceTypesTools: '' as AnyComponent,
ManageSpaceTypeContent: '' as AnyComponent,
Spaces: '' as AnyComponent,
AddSocialId: '' as AnyComponent
AddSocialId: '' as AnyComponent,
GuestPermissionsSettings: '' as AnyComponent
},
string: {
IntegrationDisabled: '' as IntlString,
+4
View File
@@ -311,6 +311,9 @@ export default plugin(settingId, {
OfficeDefaultSettings: '' as IntlString,
DefaultStartWithTranscription: '' as IntlString,
DefaultStartWithRecording: '' as IntlString,
GuestPermissionsSettings: '' as IntlString,
GuestPermissionsModulePermissions: '' as IntlString,
GuestPermissionsModulePermissionsHint: '' as IntlString,
MailboxErrorInvalidName: '' as IntlString,
MailboxErrorDomainNotFound: '' as IntlString,
MailboxErrorNameRulesViolated: '' as IntlString,
@@ -352,6 +355,7 @@ export default plugin(settingId, {
icon: {
AccountSettings: '' as Asset,
Owners: '' as Asset,
Members: '' as Asset,
Password: '' as Asset,
Setting: '' as Asset,
Integrations: '' as Asset,
+2 -1
View File
@@ -279,7 +279,8 @@
"Extensions": "Rozšíření",
"UnsetParentIssue": "Odebrat nadřazený úkol",
"ForbidCreateProjectPermission": "Zakázat vytvoření projektu",
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty"
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty",
"AllowCreatingIssues": "Povolit vytváření úkolů"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "Erweiterungen",
"UnsetParentIssue": "Übergeordnete Aufgabe entfernen",
"ForbidCreateProjectPermission": "Projekterstellung verbieten",
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte"
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte",
"AllowCreatingIssues": "Erstellen von Aufgaben erlauben"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Forbid create project",
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects"
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects",
"AllowCreatingIssues": "Allow creating issues"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Prohibir crear proyecto",
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos"
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos",
"AllowCreatingIssues": "Permitir crear incidencias"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Désélectionner l'issue parent",
"ForbidCreateProjectPermission": "Interdire la création de projet",
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets"
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets",
"AllowCreatingIssues": "Autoriser la création d'issues"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Estensioni",
"UnsetParentIssue": "Annulla l'issue genitore",
"ForbidCreateProjectPermission": "Vieta creazione progetto",
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti"
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti",
"AllowCreatingIssues": "Consenti la creazione di issue"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "拡張機能",
"UnsetParentIssue": "親イシューの設定を解除",
"ForbidCreateProjectPermission": "プロジェクト作成禁止",
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します"
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します",
"AllowCreatingIssues": "イシューの作成を許可"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos"
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
"AllowCreatingIssues": "Permitir criar problemas"
},
"status": {}
}
+2 -1
View File
@@ -272,7 +272,8 @@
"Extensions": "Extensions",
"UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos"
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
"AllowCreatingIssues": "Permitir criar problemas"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "Дополнительно",
"UnsetParentIssue": "Снять родительскую задачу",
"ForbidCreateProjectPermission": "Запретить создание проекта",
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты"
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты",
"AllowCreatingIssues": "Разрешить создание задач"
},
"status": {}
}
+2 -1
View File
@@ -270,7 +270,8 @@
"DefaultIssueStatus": "Varsayılan sorun durumu",
"IssueStatus": "Durum",
"Extensions": "Uzantılar",
"UnsetParentIssue": "Üst sorunu kaldır"
"UnsetParentIssue": "Üst sorunu kaldır",
"AllowCreatingIssues": "Sorun oluşturmaya izin ver"
},
"status": {}
}
+2 -1
View File
@@ -289,7 +289,8 @@
"Extensions": "扩展",
"UnsetParentIssue": "取消父问题",
"ForbidCreateProjectPermission": "禁止创建项目",
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目"
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目",
"AllowCreatingIssues": "允许创建问题"
},
"status": {}
}
+1
View File
@@ -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í",
+1
View File
@@ -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",
+1
View File
@@ -81,6 +81,7 @@
"TrainingStateDraft": "Draft",
"TrainingStateReleased": "Released",
"TrainingTake": "Take training",
"AllowToTakeTraining": "Allow to take training",
"TrainingTitle": "Title",
"Trainings": "Trainings",
"ViewAllTrainings": "All Trainings",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -81,6 +81,7 @@
"TrainingStateDraft": "下書き",
"TrainingStateReleased": "リリース済み",
"TrainingTake": "トレーニングを受ける",
"AllowToTakeTraining": "トレーニングを受講できるようにする",
"TrainingTitle": "タイトル",
"Trainings": "トレーニング",
"ViewAllTrainings": "すべてのトレーニング",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -81,6 +81,7 @@
"TrainingStateDraft": "Рабочая копия",
"TrainingStateReleased": "Актуальный",
"TrainingTake": "Пройти тренинг ещё раз",
"AllowToTakeTraining": "Разрешить проходить тренинг",
"TrainingTitle": "Заголовок",
"Trainings": "Тренинги",
"ViewAllTrainings": "Все тренинги",
+1
View File
@@ -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",
+1
View File
@@ -81,6 +81,7 @@
"TrainingStateDraft": "草稿",
"TrainingStateReleased": "已发布",
"TrainingTake": "参加培训",
"AllowToTakeTraining": "允许参加培训",
"TrainingTitle": "标题",
"Trainings": "培训",
"ViewAllTrainings": "所有培训",
+1
View File
@@ -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,
@@ -14,7 +14,7 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import core, { AccountRole, getCurrentAccount, type Ref } from '@hcengineering/core'
import core, { AccountRole, getCurrentAccount, type ModulePermissionGroup, type Ref } from '@hcengineering/core'
import { createNotificationsQuery, createQuery } from '@hcengineering/presentation'
import { Scroller, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { NavLink } from '@hcengineering/view-resources'
@@ -36,7 +36,7 @@
const dispatch = createEventDispatcher()
function getClickHandler (app: Application, customProps: any) {
function getClickHandler (app: Application, customProps: any): () => void {
return (
customProps.onClick ??
(() => {
@@ -46,10 +46,34 @@
}
let loaded: boolean = false
let permissionsLoaded: boolean = false
let hiddenAppsIds: Array<Ref<Application>> = []
let excludedApps: string[] = []
let disabledApplications: Set<Ref<Application>> = new Set<Ref<Application>>()
const hiddenAppsIdsQuery = createQuery()
const modulePermissionGroupsQuery = createQuery()
modulePermissionGroupsQuery.query(core.class.ModulePermissionGroup, {}, (res) => {
try {
const modulePermissionGroups = res as ModulePermissionGroup[]
disabledApplications = new Set<Ref<Application>>(
modulePermissionGroups
.filter((g) => {
if (g.enabled ?? true) return false
const role = getCurrentAccount().role
if (role === g.role) return true
// DocGuest / ReadOnlyGuest should also respect Guest module disables.
return (role === AccountRole.DocGuest || role === AccountRole.ReadOnlyGuest) && g.role === AccountRole.Guest
})
.map((g) => g.application as Ref<Application>)
)
} catch (error) {
console.error('Error loading module permission groups:', error)
} finally {
permissionsLoaded = true
}
})
hiddenAppsIdsQuery.query(
workbench.class.HiddenApplication,
{
@@ -86,22 +110,20 @@
updateExcludedApps()
function isAppVisibleInSwitcher (app: Application, disabledModules: Set<Ref<Application>>): boolean {
return !hiddenAppsIds.includes(app._id) && !excludedApps.includes(app.alias) && !disabledModules.has(app._id)
}
$: topApps = apps
.filter((it) => it.position === 'top' && !hiddenAppsIds.includes(it._id) && !excludedApps.includes(it.alias))
.filter((it) => it.position === 'top' && isAppVisibleInSwitcher(it, disabledApplications))
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
$: midApps = apps
.filter(
(it) =>
!hiddenAppsIds.includes(it._id) &&
!excludedApps.includes(it.alias) &&
it.position !== 'top' &&
it.position !== 'bottom'
(it) => it.position !== 'top' && it.position !== 'bottom' && isAppVisibleInSwitcher(it, disabledApplications)
)
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
$: bottomApps = apps.filter(
(it) => it.position === 'bottom' && !hiddenAppsIds.includes(it._id) && !excludedApps.includes(it.alias)
)
$: bottomApps = apps.filter((it) => it.position === 'bottom' && isAppVisibleInSwitcher(it, disabledApplications))
const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotificationsByContextStore = inboxClient.inboxNotificationsByContext
@@ -135,7 +157,7 @@
</script>
<div class="flex-{direction === 'horizontal' ? 'row-center' : 'col-center'} clear-mins apps-{direction} relative">
{#if loaded}
{#if loaded && permissionsLoaded}
<Scroller
invertScroll
padding={direction === 'horizontal' ? '.75rem .5rem' : '.5rem .75rem'}