mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-06 09:47:43 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+6
@@ -40441,6 +40441,9 @@ importers:
|
||||
'@hcengineering/analytics-service':
|
||||
specifier: workspace:^0.7.19
|
||||
version: link:../../../foundations/core/packages/analytics-service
|
||||
'@hcengineering/api-client':
|
||||
specifier: workspace:^0.7.25
|
||||
version: link:../../../foundations/core/packages/api-client
|
||||
'@hcengineering/core':
|
||||
specifier: workspace:^0.7.26
|
||||
version: link:../../../foundations/core/packages/core
|
||||
@@ -40450,6 +40453,9 @@ importers:
|
||||
'@hcengineering/server-core':
|
||||
specifier: workspace:^0.7.19
|
||||
version: link:../../../foundations/server/packages/core
|
||||
'@hcengineering/server-guest-resources':
|
||||
specifier: workspace:^0.7.0
|
||||
version: link:../../../server-plugins/guest-resources
|
||||
'@hcengineering/server-storage':
|
||||
specifier: workspace:^0.7.16
|
||||
version: link:../../../foundations/server/packages/server-storage
|
||||
|
||||
@@ -415,6 +415,7 @@ services:
|
||||
- STATS_URL=http://huly.local:4900
|
||||
- ACCOUNTS_URL=http://huly.local:3000
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
|
||||
- FRONT_URL=http://huly.local:8087
|
||||
sign:
|
||||
image: hardcoreeng/sign
|
||||
extra_hosts:
|
||||
|
||||
@@ -153,6 +153,7 @@ services:
|
||||
- MONGO_URL=mongodb://huly.local:27017?compressors=snappy
|
||||
- 'MONGO_OPTIONS={"appName":"print","maxPoolSize":1}'
|
||||
- STORAGE_CONFIG=${STORAGE_CONFIG}
|
||||
- FRONT_URL=http://huly.local:8087
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
+2
-22
@@ -331,28 +331,10 @@ export function devTool (
|
||||
// })
|
||||
// })
|
||||
|
||||
function parseAccountRole (raw: unknown): AccountRole {
|
||||
const rawRole = typeof raw === 'string' ? raw.trim() : String(raw ?? 'User').trim()
|
||||
const normalized = rawRole.trim().toLowerCase()
|
||||
|
||||
const match = Object.values(AccountRole).find((v) => v.toLowerCase() === normalized)
|
||||
if (match !== undefined) return match
|
||||
|
||||
switch (normalized) {
|
||||
case 'readonly':
|
||||
return AccountRole.ReadOnlyGuest
|
||||
case 'docguest':
|
||||
return AccountRole.DocGuest
|
||||
default:
|
||||
throw new Error(`Unknown role: ${rawRole}`)
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('assign-workspace <email> <workspace>')
|
||||
.description('assign workspace')
|
||||
.option('--role <role>', 'Workspace role (User, Guest, ReadOnlyGuest, DocGuest, Maintainer, Owner, Admin)', 'User')
|
||||
.action(async (email: string, workspace: string, cmd: { role: string }) => {
|
||||
.action(async (email: string, workspace: string, cmd) => {
|
||||
await withAccountDatabase(async (db) => {
|
||||
console.log(`assigning user ${email} to ${workspace}...`)
|
||||
try {
|
||||
@@ -361,12 +343,10 @@ export function devTool (
|
||||
throw new Error(`Workspace ${workspace} not found`)
|
||||
}
|
||||
|
||||
const role = cmd.role != null ? parseAccountRole(cmd.role) : AccountRole.User
|
||||
|
||||
await assignWorkspace(toolCtx, db, null, getToolToken(), {
|
||||
email,
|
||||
workspaceUuid: ws.uuid,
|
||||
role
|
||||
role: AccountRole.User
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// 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 Class,
|
||||
Client,
|
||||
type Doc,
|
||||
type DocumentQuery,
|
||||
type DomainParams,
|
||||
type DomainRequestOptions,
|
||||
type DomainResult,
|
||||
type FindOptions,
|
||||
type FindResult,
|
||||
Hierarchy,
|
||||
ModelDb,
|
||||
OperationDomain,
|
||||
type Ref,
|
||||
type SearchOptions,
|
||||
type SearchQuery,
|
||||
type SearchResult,
|
||||
type Tx,
|
||||
type TxResult,
|
||||
type WithLookup
|
||||
} from '@hcengineering/core'
|
||||
|
||||
import type { RestClient } from './types'
|
||||
|
||||
export class RestClientAdapter implements Client {
|
||||
constructor (
|
||||
private readonly client: RestClient,
|
||||
private readonly hierarchy: Hierarchy | undefined,
|
||||
private readonly model: ModelDb | undefined
|
||||
) {}
|
||||
|
||||
async domainRequest<T>(
|
||||
domain: OperationDomain,
|
||||
params: DomainParams,
|
||||
options?: DomainRequestOptions
|
||||
): Promise<DomainResult<T>> {
|
||||
return await this.client.domainRequest(domain, params, options)
|
||||
}
|
||||
|
||||
async findAll<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
): Promise<FindResult<T>> {
|
||||
return await this.client.findAll(_class, query, options)
|
||||
}
|
||||
|
||||
async tx (tx: Tx): Promise<TxResult> {
|
||||
return await this.client.tx(tx)
|
||||
}
|
||||
|
||||
async findOne<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
): Promise<WithLookup<T> | undefined> {
|
||||
return await this.client.findOne(_class, query, options)
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return await this.client.searchFulltext(query, options)
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
// No ned to close the REST client
|
||||
}
|
||||
|
||||
getHierarchy (): Hierarchy {
|
||||
if (this.hierarchy === undefined) {
|
||||
throw new Error('Hierarchy is not defined')
|
||||
}
|
||||
return this.hierarchy
|
||||
}
|
||||
|
||||
getModel (): ModelDb {
|
||||
if (this.model === undefined) {
|
||||
throw new Error('Model is not defined')
|
||||
}
|
||||
return this.model
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export { RestClientAdapter } from './adapter'
|
||||
export { createRestClient, connectRest } from './rest'
|
||||
export { createRestTxOperations } from './tx'
|
||||
export * from './types'
|
||||
|
||||
@@ -613,54 +613,6 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
|
||||
return domain === 'tx' ? 'objectSpace' : domain === 'space' ? '_id' : 'space'
|
||||
}
|
||||
|
||||
private mergeDocIdRestriction<T extends Doc>(query: DocumentQuery<T>, allowed: Ref<T>[]): DocumentQuery<T> {
|
||||
const allowedIds: DocumentQuery<T>['_id'] = { $in: allowed.length === 0 ? [] : allowed }
|
||||
const prevId = query._id
|
||||
if (prevId === undefined) {
|
||||
return { ...query, _id: allowedIds }
|
||||
}
|
||||
type WithAnd = DocumentQuery<T> & { $and?: DocumentQuery<T>[] }
|
||||
const { _id: _drop, $and, ...rest } = query as WithAnd
|
||||
const andParts: DocumentQuery<T>[] = [...($and ?? []), { _id: prevId }, { _id: allowedIds }]
|
||||
const merged: DocumentQuery<T> = { ...rest, $and: andParts }
|
||||
return merged
|
||||
}
|
||||
|
||||
private async applyGuestCollaboratorReadRestriction<T extends Doc>(
|
||||
ctx: MeasureContext<SessionData>,
|
||||
_class: Ref<Class<T>>,
|
||||
domain: Domain,
|
||||
query: DocumentQuery<T>
|
||||
): Promise<DocumentQuery<T>> {
|
||||
const account = ctx.contextData.account
|
||||
if (
|
||||
![AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) ||
|
||||
isSystem(account, ctx) ||
|
||||
domain === DOMAIN_MODEL
|
||||
) {
|
||||
return query
|
||||
}
|
||||
|
||||
const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, _class)
|
||||
if (collabSec?.provideSecurity !== true) {
|
||||
return query
|
||||
}
|
||||
|
||||
const rootClass = collabSec.attachedTo
|
||||
const docClasses = [...this.context.hierarchy.getDescendants(rootClass), rootClass]
|
||||
const collabs = (await this.provideFindAll(
|
||||
ctx,
|
||||
core.class.Collaborator,
|
||||
{
|
||||
collaborator: account.uuid,
|
||||
attachedToClass: { $in: docClasses }
|
||||
},
|
||||
{ projection: { attachedTo: 1 }, limit: 10_000 }
|
||||
)) as Collaborator[]
|
||||
const allowed = collabs.map((c) => c.attachedTo) as Ref<T>[]
|
||||
return this.mergeDocIdRestriction(query, allowed)
|
||||
}
|
||||
|
||||
override async findAll<T extends Doc>(
|
||||
ctx: MeasureContext<SessionData>,
|
||||
_class: Ref<Class<T>>,
|
||||
@@ -727,10 +679,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
|
||||
}
|
||||
}
|
||||
|
||||
let queryToUse = !this.skipFindCheck ? newQuery : query
|
||||
queryToUse = await this.applyGuestCollaboratorReadRestriction(ctx, _class, domain, queryToUse)
|
||||
|
||||
let findResult = await this.provideFindAll(ctx, _class, queryToUse, options)
|
||||
let findResult = await this.provideFindAll(ctx, _class, !this.skipFindCheck ? newQuery : query, options)
|
||||
if (clientFilterSpaces !== undefined) {
|
||||
const cfs = clientFilterSpaces
|
||||
findResult = toFindResult(
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
//
|
||||
// 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 core, {
|
||||
AccountRole,
|
||||
MeasureMetricsContext,
|
||||
generateId,
|
||||
toFindResult,
|
||||
type Account,
|
||||
type AccountUuid,
|
||||
type Class,
|
||||
type ClassCollaborators,
|
||||
type Collaborator,
|
||||
type Doc,
|
||||
type Domain,
|
||||
type MeasureContext,
|
||||
type PersonId,
|
||||
type Ref,
|
||||
type SessionData
|
||||
} from '@hcengineering/core'
|
||||
import type { Middleware, PipelineContext } from '@hcengineering/server-core'
|
||||
import { SpaceSecurityMiddleware } from '../spaceSecurity'
|
||||
|
||||
const DOC_CLASS = 'test:class:Doc' as Ref<Class<Doc>>
|
||||
const DOC_ID = 'test:doc:1' as Ref<Doc>
|
||||
const TEST_DOMAIN = 'test-domain' as Domain
|
||||
|
||||
function makeAccount (role: AccountRole): Account {
|
||||
return {
|
||||
uuid: generateId() as unknown as AccountUuid,
|
||||
role,
|
||||
primarySocialId: 'test-social' as PersonId,
|
||||
socialIds: ['test-social' as PersonId],
|
||||
fullSocialIds: []
|
||||
}
|
||||
}
|
||||
|
||||
function makeCtx (account: Account): MeasureContext<SessionData> {
|
||||
const ctx = new MeasureMetricsContext('test', {}) as MeasureContext<SessionData>
|
||||
ctx.contextData = {
|
||||
account,
|
||||
broadcast: { txes: [], queue: [], sessions: {} },
|
||||
socialStringsToUsers: new Map(),
|
||||
contextCache: new Map()
|
||||
} as unknown as SessionData
|
||||
return ctx
|
||||
}
|
||||
|
||||
function makeMiddleware (
|
||||
role: AccountRole,
|
||||
opts: { provideSecurity: boolean } = { provideSecurity: false }
|
||||
): { mw: SpaceSecurityMiddleware, account: Account, calls: Array<{ cls: Ref<Class<Doc>>, query: any }> } {
|
||||
const account = makeAccount(role)
|
||||
const calls: Array<{ cls: Ref<Class<Doc>>, query: any }> = []
|
||||
const collabMixin = {
|
||||
_id: generateId(),
|
||||
_class: core.class.ClassCollaborators,
|
||||
space: core.space.Model,
|
||||
attachedTo: DOC_CLASS,
|
||||
fields: ['createdBy'],
|
||||
provideSecurity: opts.provideSecurity,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: core.account.System
|
||||
} as unknown as ClassCollaborators<Doc>
|
||||
|
||||
// Partial Hierarchy test double — SpaceSecurityMiddleware only needs these methods for this test.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- full Hierarchy is not needed here
|
||||
const hierarchy = {
|
||||
getDomain: (_class: Ref<Class<Doc>>) => TEST_DOMAIN,
|
||||
isDerived: (_class: Ref<Class<Doc>>, base: Ref<Class<Doc>>) =>
|
||||
base === core.class.Space && _class === core.class.Space,
|
||||
getDescendants: (_class: Ref<Class<Doc>>) => [] as Ref<Class<Doc>>[],
|
||||
getAncestors: (_class: Ref<Class<Doc>>) => [_class]
|
||||
} as PipelineContext['hierarchy']
|
||||
|
||||
const modelDb = {
|
||||
findAllSync: (cl: Ref<Class<Doc>>, query: { attachedTo?: { $in?: Ref<Class<Doc>>[] } }) => {
|
||||
if (cl !== core.class.ClassCollaborators) return []
|
||||
const ids = query.attachedTo?.$in ?? []
|
||||
return ids.includes(DOC_CLASS) ? [collabMixin] : []
|
||||
}
|
||||
} as unknown as PipelineContext['modelDb']
|
||||
|
||||
const next: Middleware = {
|
||||
findAll: async <T extends Doc>(_ctx: MeasureContext<SessionData>, _class: Ref<Class<T>>, query: any) => {
|
||||
calls.push({ cls: _class as unknown as Ref<Class<Doc>>, query: JSON.parse(JSON.stringify(query)) })
|
||||
if (_class === (core.class.Space as unknown as Ref<Class<T>>)) {
|
||||
return toFindResult([]) as any
|
||||
}
|
||||
if (_class === (core.class.Collaborator as unknown as Ref<Class<T>>)) {
|
||||
const collabDoc: Collaborator = {
|
||||
_id: generateId(),
|
||||
_class: core.class.Collaborator,
|
||||
space: core.space.Workspace,
|
||||
attachedTo: DOC_ID,
|
||||
attachedToClass: DOC_CLASS,
|
||||
collection: 'collaborators',
|
||||
collaborator: account.uuid,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: core.account.System
|
||||
}
|
||||
return toFindResult([collabDoc]) as any
|
||||
}
|
||||
return toFindResult([]) as any
|
||||
},
|
||||
tx: async () => ({}),
|
||||
groupBy: async () => new Map(),
|
||||
searchFulltext: async () => ({ docs: [] }) as any,
|
||||
handleBroadcast: async () => {},
|
||||
loadModel: async () => [],
|
||||
domainRequest: async () => ({ value: undefined }) as any,
|
||||
closeSession: async () => {},
|
||||
close: async () => {}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal PipelineContext stub for unit test
|
||||
const context = {
|
||||
workspace: { uuid: generateId() as any, url: 'test', dataId: 'test' as any },
|
||||
hierarchy,
|
||||
modelDb,
|
||||
branding: null as any,
|
||||
adapterManager: {} as any,
|
||||
storageAdapter: {} as any,
|
||||
contextVars: {},
|
||||
lastTx: '',
|
||||
lastHash: '',
|
||||
broadcastEvent: async () => {}
|
||||
} as PipelineContext
|
||||
|
||||
const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware
|
||||
return { mw, account, calls }
|
||||
}
|
||||
|
||||
describe('SpaceSecurityMiddleware guest collaborator read restriction', () => {
|
||||
it('applies collaborator _id filter for guest when provideSecurity is enabled', async () => {
|
||||
const { mw, account, calls } = makeMiddleware(AccountRole.Guest, { provideSecurity: true })
|
||||
const ctx = makeCtx(account)
|
||||
|
||||
await mw.findAll(ctx, DOC_CLASS, { title: 'Meeting minutes' })
|
||||
|
||||
expect(calls).toHaveLength(3)
|
||||
expect(calls[1].cls).toBe(core.class.Collaborator)
|
||||
expect(calls[2].cls).toBe(DOC_CLASS)
|
||||
expect(calls[2].query).toEqual({
|
||||
title: 'Meeting minutes',
|
||||
_id: { $in: [DOC_ID] }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps query unchanged for regular user', async () => {
|
||||
const { mw, account, calls } = makeMiddleware(AccountRole.User, { provideSecurity: true })
|
||||
const ctx = makeCtx(account)
|
||||
|
||||
await mw.findAll(ctx, DOC_CLASS, { title: 'Meeting minutes' })
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1].cls).toBe(DOC_CLASS)
|
||||
expect(calls[1].query).toEqual({ title: 'Meeting minutes' })
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@
|
||||
import cardPlugin, { cardId, DOMAIN_CARD, type Card, type Role } from '@hcengineering/card'
|
||||
import core, {
|
||||
DOMAIN_MODEL,
|
||||
SortingOrder,
|
||||
TxOperations,
|
||||
type Class,
|
||||
type ClassPermission,
|
||||
@@ -71,7 +72,7 @@ export const cardOperation: MigrateOperation = {
|
||||
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>, mode): Promise<void> {
|
||||
await tryUpgrade(mode, state, client, cardId, [
|
||||
{
|
||||
state: 'migrateViewlets-v7',
|
||||
state: 'migrateViewlets-6',
|
||||
func: migrateViewlets
|
||||
},
|
||||
{
|
||||
@@ -128,6 +129,11 @@ export const cardOperation: MigrateOperation = {
|
||||
state: 'migrate-restricted-permissions',
|
||||
mode: 'upgrade',
|
||||
func: migrateRestrictedPermissions
|
||||
},
|
||||
{
|
||||
state: 'add-grid-viewlet',
|
||||
mode: 'upgrade',
|
||||
func: addGridViewlet
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -286,6 +292,40 @@ async function migrateRestrictedPermissions (_client: MigrationUpgradeClient): P
|
||||
}
|
||||
}
|
||||
|
||||
async function addGridViewlet (client: MigrationUpgradeClient): Promise<void> {
|
||||
const txOp = new TxOperations(client, core.account.System)
|
||||
const masterTags = await client.findAll(card.class.MasterTag, {})
|
||||
const currentViewlets = await client.findAll(view.class.Viewlet, {
|
||||
descriptor: card.viewlet.CardGridDescriptor,
|
||||
attachTo: { $in: masterTags.map((p) => p._id) }
|
||||
})
|
||||
for (const masterTag of masterTags) {
|
||||
const current = currentViewlets.find((p) => p.attachTo === masterTag._id)
|
||||
if (current === undefined) {
|
||||
await txOp.createDoc(view.class.Viewlet, core.space.Model, {
|
||||
descriptor: card.viewlet.CardGridDescriptor,
|
||||
baseQuery: {
|
||||
isLatest: true
|
||||
},
|
||||
config: [''],
|
||||
configOptions: {
|
||||
strict: true
|
||||
},
|
||||
viewOptions: {
|
||||
groupBy: [],
|
||||
orderBy: [
|
||||
['modifiedOn', SortingOrder.Descending],
|
||||
['rank', SortingOrder.Ascending],
|
||||
['title', SortingOrder.Descending]
|
||||
],
|
||||
other: []
|
||||
},
|
||||
attachTo: masterTag._id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addVersionForVersionableTypes (client: MigrationUpgradeClient): Promise<void> {
|
||||
const txOp = new TxOperations(client, core.account.System)
|
||||
const versionableTypes = await client.findAll(card.class.MasterTag, {})
|
||||
|
||||
@@ -461,6 +461,18 @@ export function defineFunctions (builder: Builder): void {
|
||||
process.function.RoleContext
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
process.class.ProcessFunction,
|
||||
core.space.Model,
|
||||
{
|
||||
of: core.class.TypeAny,
|
||||
category: 'attribute',
|
||||
label: process.string.EmptyValue,
|
||||
type: 'context'
|
||||
},
|
||||
process.function.EmptyValue
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
process.class.ProcessFunction,
|
||||
core.space.Model,
|
||||
|
||||
@@ -343,6 +343,10 @@ export function createModel (builder: Builder): void {
|
||||
func: serverProcess.transform.RemoveLast
|
||||
})
|
||||
|
||||
builder.mixin(process.function.EmptyValue, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
|
||||
func: serverProcess.transform.EmptyValue
|
||||
})
|
||||
|
||||
builder.mixin(process.function.EmptyArray, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
|
||||
func: serverProcess.transform.EmptyArray
|
||||
})
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Class, Doc, DocumentQuery, FindOptions, Ref, WithLookup } from '@hcengineering/core'
|
||||
import { type Card } from '@hcengineering/card'
|
||||
import type { Class, Doc, DocumentQuery, FindOptions, Ref, WithLookup } from '@hcengineering/core'
|
||||
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Scroller } from '@hcengineering/ui'
|
||||
import { BuildModelKey, ViewOptions } from '@hcengineering/view'
|
||||
import { ListSelectionProvider, SelectDirection, buildConfigLookup, focusStore } from '@hcengineering/view-resources'
|
||||
import CardGridItem from './CardGridItem.svelte'
|
||||
@@ -71,20 +72,22 @@
|
||||
|
||||
<ActionContext context={{ mode: 'browser' }} />
|
||||
|
||||
<div class="grid-container">
|
||||
{#each objects as object, i}
|
||||
{@const selected = selection === i}
|
||||
<div class="grid-cell">
|
||||
<CardGridItem
|
||||
{object}
|
||||
{selected}
|
||||
on:obj-focus={(evt) => {
|
||||
listProvider.updateFocus(evt.detail)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Scroller fade={{ multipler: { top: 3, bottom: 2.5 } }} padding={'0 1rem'} checkForHeaders>
|
||||
<div class="grid-container">
|
||||
{#each objects as object, i}
|
||||
{@const selected = selection === i}
|
||||
<div class="grid-cell">
|
||||
<CardGridItem
|
||||
{object}
|
||||
{selected}
|
||||
on:obj-focus={(evt) => {
|
||||
listProvider.updateFocus(evt.detail)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Scroller>
|
||||
|
||||
<style lang="scss">
|
||||
.grid-container {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { Asset, getEmbeddedLabel, IntlString } from '@hcengineering/platform'
|
||||
import { getAttributePresenterClass, getClient, hasResource } from '@hcengineering/presentation'
|
||||
import { resizeObserver } from '@hcengineering/ui'
|
||||
import view, { BuildModelKey, Viewlet, ViewletPreference } from '@hcengineering/view'
|
||||
import view, { BuildModelKey, Viewlet } from '@hcengineering/view'
|
||||
import {
|
||||
buildConfigLookup,
|
||||
canResolveAttribute,
|
||||
@@ -34,7 +34,7 @@
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
$: citems = getConfig(viewlet, undefined)
|
||||
$: citems = getConfig(viewlet)
|
||||
|
||||
interface Config {
|
||||
value: string | BuildModelKey | undefined
|
||||
@@ -232,7 +232,7 @@
|
||||
return false
|
||||
}
|
||||
|
||||
function getConfig (viewlet: Viewlet, preference: ViewletPreference | undefined): Config[] {
|
||||
function getConfig (viewlet: Viewlet): Config[] {
|
||||
const result = getBaseConfig(viewlet)
|
||||
if (viewlet.configOptions?.strict !== true) {
|
||||
const allAttributes = hierarchy.getAllAttributes(viewlet.attachTo)
|
||||
@@ -248,15 +248,10 @@
|
||||
})
|
||||
}
|
||||
|
||||
addAssociations(result, viewlet.attachTo, preference)
|
||||
addAssociations(result, viewlet.attachTo)
|
||||
}
|
||||
|
||||
function addAssociations (
|
||||
result: Config[],
|
||||
_class: Ref<Class<Doc>>,
|
||||
preference: ViewletPreference | undefined,
|
||||
parents: AssociationQuery[] = []
|
||||
): void {
|
||||
function addAssociations (result: Config[], _class: Ref<Class<Doc>>, parents: AssociationQuery[] = []): void {
|
||||
const ancestors = new Set(hierarchy.getAncestors(_class))
|
||||
const parent = hierarchy.getParentClass(_class)
|
||||
const parentMixins = hierarchy
|
||||
@@ -276,10 +271,10 @@
|
||||
const associationsA = client.getModel().findAllSync(core.class.Association, { classB: { $in: allClasses } })
|
||||
|
||||
associationsB.forEach((a) => {
|
||||
processAssociation(a, 'b', result, preference, parents)
|
||||
processAssociation(a, 'b', result, parents)
|
||||
})
|
||||
associationsA.forEach((a) => {
|
||||
processAssociation(a, 'a', result, preference, parents)
|
||||
processAssociation(a, 'a', result, parents)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,7 +286,6 @@
|
||||
association: Association,
|
||||
direction: 'a' | 'b',
|
||||
result: Config[],
|
||||
preference: ViewletPreference | undefined,
|
||||
parents: AssociationQuery[]
|
||||
): void {
|
||||
const associationName = `$associations.${association._id}_${direction}`
|
||||
@@ -327,17 +321,16 @@
|
||||
result.push(newValue)
|
||||
}
|
||||
|
||||
if (preference === undefined) return
|
||||
const exists = preference.config.find((p) => {
|
||||
const key = typeof p === 'string' ? p : p.key
|
||||
const exists = result.find((p) => {
|
||||
const key = typeof p.value === 'string' ? p.value : p.value?.key
|
||||
return key === resultName
|
||||
})
|
||||
if (exists) {
|
||||
addAssociations(result, targetClass, preference, [...parents, [association._id, direction === 'a' ? 1 : -1]])
|
||||
if ((exists as AttributeConfig)?.enabled) {
|
||||
addAssociations(result, targetClass, [...parents, [association._id, direction === 'a' ? 1 : -1]])
|
||||
}
|
||||
}
|
||||
|
||||
return preference === undefined ? result : []
|
||||
return result
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -353,6 +346,8 @@
|
||||
// TODO UBERF-9639: restore defaults
|
||||
}}
|
||||
on:save={(event) => {
|
||||
viewlet.config = event.detail
|
||||
viewlet = viewlet
|
||||
dispatch('update', event.detail)
|
||||
}}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
<span class="flex-presenter">
|
||||
<span class="mr-1"><Icon {icon} size="small" /></span>
|
||||
<Label label={communication.string.Unset} />
|
||||
<span class="lower"><Label label={model.label} /></span>
|
||||
<span class="lower ml-1"><Label label={model.label} /></span>
|
||||
</span>
|
||||
{:else}
|
||||
<ActivityAttributeValue {model} {icon} values={value}>
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
"YearFromDate": "Rok z data",
|
||||
"MonthFromDate": "Měsíc z data",
|
||||
"DayFromDate": "Den z data",
|
||||
"DateDifference": "Rozdíl dat"
|
||||
"DateDifference": "Rozdíl dat",
|
||||
"EmptyValue": "Prázdná hodnota"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Metoda nenalezena: {methodId}",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
"YearFromDate": "Jahr aus Datum",
|
||||
"MonthFromDate": "Monat aus Datum",
|
||||
"DayFromDate": "Tag aus Datum",
|
||||
"DateDifference": "Datumsdifferenz"
|
||||
"DateDifference": "Datumsdifferenz",
|
||||
"EmptyValue": "Leerer Wert"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Methode nicht gefunden: {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "Year from date",
|
||||
"MonthFromDate": "Month from date",
|
||||
"DayFromDate": "Day from date",
|
||||
"DateDifference": "Date difference"
|
||||
"DateDifference": "Date difference",
|
||||
"EmptyValue": "Empty value"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Method not found: {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "Año desde fecha",
|
||||
"MonthFromDate": "Mes desde fecha",
|
||||
"DayFromDate": "Día desde fecha",
|
||||
"DateDifference": "Diferencia de fechas"
|
||||
"DateDifference": "Diferencia de fechas",
|
||||
"EmptyValue": "Valor vacío"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Método no encontrado: {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "Année depuis date",
|
||||
"MonthFromDate": "Mois depuis date",
|
||||
"DayFromDate": "Jour depuis date",
|
||||
"DateDifference": "Différence de dates"
|
||||
"DateDifference": "Différence de dates",
|
||||
"EmptyValue": "Valeur vide"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Méthode introuvable : {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "Anno da data",
|
||||
"MonthFromDate": "Mese da data",
|
||||
"DayFromDate": "Giorno da data",
|
||||
"DateDifference": "Differenza di date"
|
||||
"DateDifference": "Differenza di date",
|
||||
"EmptyValue": "Valore vuoto"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Metodo non trovato: {methodId}",
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
"YearFromDate": "日付から年",
|
||||
"MonthFromDate": "日付から月",
|
||||
"DayFromDate": "日付から日",
|
||||
"DateDifference": "日付の差"
|
||||
"DateDifference": "日付の差",
|
||||
"EmptyValue": "空の値"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "メソッドが見つかりません: {methodId}",
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"YearFromDate": "Ano de data",
|
||||
"MonthFromDate": "Mês de data",
|
||||
"DayFromDate": "Dia de data",
|
||||
"DateDifference": "Diferença de datas"
|
||||
"DateDifference": "Diferença de datas",
|
||||
"EmptyValue": "Valor vazio"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Método não encontrado: {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "Ano de data",
|
||||
"MonthFromDate": "Mês de data",
|
||||
"DayFromDate": "Dia de data",
|
||||
"DateDifference": "Diferença de datas"
|
||||
"DateDifference": "Diferença de datas",
|
||||
"EmptyValue": "Valor vazio"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Método não encontrado: {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "Год из даты",
|
||||
"MonthFromDate": "Месяц из даты",
|
||||
"DayFromDate": "День из даты",
|
||||
"DateDifference": "Разница дат"
|
||||
"DateDifference": "Разница дат",
|
||||
"EmptyValue": "Пустое значение"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Метод не найден: {methodId}",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
"YearFromDate": "Tarihten yıl",
|
||||
"MonthFromDate": "Tarihten ay",
|
||||
"DayFromDate": "Tarihten gün",
|
||||
"DateDifference": "Tarih farkı"
|
||||
"DateDifference": "Tarih farkı",
|
||||
"EmptyValue": "Boş değer"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Metod bulunamadı: {methodId}",
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
"YearFromDate": "日期到年份",
|
||||
"MonthFromDate": "日期到月份",
|
||||
"DayFromDate": "日期到天",
|
||||
"DateDifference": "日期差异"
|
||||
"DateDifference": "日期差异",
|
||||
"EmptyValue": "空值"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "找不到方法:{methodId}",
|
||||
|
||||
@@ -233,6 +233,7 @@ export default mergeIds(processId, process, {
|
||||
For: '' as IntlString,
|
||||
Attribute: '' as IntlString,
|
||||
Context: '' as IntlString,
|
||||
EmptyValue: '' as IntlString,
|
||||
EmptyArray: '' as IntlString,
|
||||
ExecutionInitiator: '' as IntlString,
|
||||
ExecutionStarted: '' as IntlString,
|
||||
|
||||
@@ -331,7 +331,7 @@ function getContextFunctions (
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (hierarchy.isDerived(func.of, target)) {
|
||||
if (hierarchy.isDerived(func.of, target) || func.of === core.class.TypeAny) {
|
||||
matched.push(func._id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ export default plugin(processId, {
|
||||
ExecutionStarted: '' as Ref<ProcessFunction>,
|
||||
ExecutionEmployeeInitiator: '' as Ref<ProcessFunction>,
|
||||
ExecutionInitiator: '' as Ref<ProcessFunction>,
|
||||
EmptyValue: '' as Ref<ProcessFunction>,
|
||||
EmptyArray: '' as Ref<ProcessFunction>,
|
||||
CurrentDate: '' as Ref<ProcessFunction>,
|
||||
StringFromNumber: '' as Ref<ProcessFunction>,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SupportClientFactory, SupportConversation, SupportSystem } from './type
|
||||
export * from './types'
|
||||
export { deleteSupportConversation, updateSupportConversation } from './utils'
|
||||
|
||||
export const supportLink = 'https://huly.link/slack'
|
||||
export const supportLink = 'https://link.huly.io/slack'
|
||||
export const reportBugLink = 'https://github.com/hcengineering/platform/issues/new'
|
||||
export const docsLink = 'http://docs.huly.io/'
|
||||
export const privacyPolicyLink = 'https://v1.huly.io/legal/privacy/'
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { LabelAndProps, LinkWrapper, tooltip } from '@hcengineering/ui'
|
||||
|
||||
export let value: string | string[] | undefined
|
||||
export let value: string | string[] | null | undefined
|
||||
export let accent: boolean = false
|
||||
export let oneLine: boolean = false
|
||||
|
||||
$: tooltipParams = getTooltip(value)
|
||||
|
||||
function getTooltip (value: string | string[] | undefined): LabelAndProps | undefined {
|
||||
if (value === undefined) return
|
||||
function getTooltip (value: string | string[] | null | undefined): LabelAndProps | undefined {
|
||||
if (value == null) return
|
||||
let str = ''
|
||||
if (Array.isArray(value)) {
|
||||
str = value.reduce((acc, curr, i) => (acc += i === 0 ? curr : ` ${curr}`), '')
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
export let loading: boolean = false
|
||||
export let notify: boolean = false
|
||||
export let navigator: boolean = false
|
||||
export let dataId: string | undefined = undefined
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -33,6 +34,7 @@
|
||||
class:selected
|
||||
class:navigator
|
||||
id={'app-' + label}
|
||||
data-id={dataId}
|
||||
disabled={loading}
|
||||
use:tooltip={{ label }}
|
||||
on:click
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
bind:this={btns[i]}
|
||||
class="ap-menuItem withIcon flex-row-center flex-grow"
|
||||
class:hover={btns[i] === activeElement}
|
||||
data-id={`app-switcher-row-${app.alias}`}
|
||||
on:click={() => {
|
||||
if (hiddenAppsIds.includes(app._id)) showApplication(app)
|
||||
else hideApplication(app)
|
||||
|
||||
@@ -110,21 +110,28 @@
|
||||
|
||||
updateExcludedApps()
|
||||
|
||||
function isAppVisibleInSwitcher (app: Application, disabledModules: Set<Ref<Application>>): boolean {
|
||||
return !hiddenAppsIds.includes(app._id) && !excludedApps.includes(app.alias) && !disabledModules.has(app._id)
|
||||
let topApps: Application[] = []
|
||||
let midApps: Application[] = []
|
||||
let bottomApps: Application[] = []
|
||||
|
||||
// Single reactive block so reads of hiddenAppsIds / excludedApps / disabledApplications
|
||||
$: {
|
||||
const hidden = hiddenAppsIds
|
||||
const excluded = excludedApps
|
||||
const disabled = disabledApplications
|
||||
|
||||
const isApplicationVisibleInSidebar = (app: Application): boolean =>
|
||||
!hidden.includes(app._id) && !excluded.includes(app.alias) && !disabled.has(app._id)
|
||||
|
||||
topApps = apps
|
||||
.filter((it) => it.position === 'top' && isApplicationVisibleInSidebar(it))
|
||||
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
|
||||
midApps = apps
|
||||
.filter((it) => it.position !== 'top' && it.position !== 'bottom' && isApplicationVisibleInSidebar(it))
|
||||
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
|
||||
bottomApps = apps.filter((it) => it.position === 'bottom' && isApplicationVisibleInSidebar(it))
|
||||
}
|
||||
|
||||
$: topApps = apps
|
||||
.filter((it) => it.position === 'top' && isAppVisibleInSwitcher(it, disabledApplications))
|
||||
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
|
||||
$: midApps = apps
|
||||
.filter(
|
||||
(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' && isAppVisibleInSwitcher(it, disabledApplications))
|
||||
|
||||
const inboxClient = InboxNotificationsClientImpl.getClient()
|
||||
const inboxNotificationsByContextStore = inboxClient.inboxNotificationsByContext
|
||||
|
||||
@@ -177,6 +184,7 @@
|
||||
navigator={app._id === active && $deviceInfo.navigator.visible}
|
||||
notify={showNotify(app.alias, hasInboxNotifications, hasNewInboxNotifications, hasNewMessagesNotification)}
|
||||
{...customProps}
|
||||
dataId={`app-sidebar-${app.alias}`}
|
||||
on:click={getClickHandler(app, customProps)}
|
||||
/>
|
||||
</NavLink>
|
||||
@@ -193,6 +201,7 @@
|
||||
label={app.label}
|
||||
navigator={app._id === active && $deviceInfo.navigator.visible}
|
||||
{...customProps}
|
||||
dataId={`app-sidebar-${app.alias}`}
|
||||
on:click={getClickHandler(app, customProps)}
|
||||
/>
|
||||
</NavLink>
|
||||
@@ -209,6 +218,7 @@
|
||||
navigator={app._id === active && $deviceInfo.navigator.visible}
|
||||
notify={app.alias === chatId && hasNewInboxNotifications}
|
||||
{...customProps}
|
||||
dataId={`app-sidebar-${app.alias}`}
|
||||
on:click={getClickHandler(app, customProps)}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
@@ -926,6 +926,7 @@
|
||||
<AppItem
|
||||
icon={IconSettings}
|
||||
label={setting.string.Customize}
|
||||
dataId="workbench-app-customize"
|
||||
size={appsMini ? 'small' : 'large'}
|
||||
on:click={() => showPopup(AppSwitcher, { apps }, popupPosition)}
|
||||
/>
|
||||
|
||||
@@ -219,6 +219,7 @@ services:
|
||||
- SECRET=secret
|
||||
- STORAGE_CONFIG=${STORAGE_CONFIG}
|
||||
- ACCOUNTS_URL=http://account:3003
|
||||
- FRONT_URL=http://huly.local:8087
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
DayFromDate,
|
||||
Divide,
|
||||
EmptyArray,
|
||||
EmptyValue,
|
||||
ExecutionInitiator,
|
||||
ExecutionStarted,
|
||||
Filter,
|
||||
@@ -680,6 +681,7 @@ export default async () => ({
|
||||
RemoveFirst,
|
||||
RemoveLast,
|
||||
EmptyArray,
|
||||
EmptyValue,
|
||||
ExecutionInitiator,
|
||||
ExecutionStarted,
|
||||
FirstMatchValue,
|
||||
|
||||
@@ -463,6 +463,10 @@ export async function CurrentDate (): Promise<Timestamp> {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
export function EmptyValue (): null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function EmptyArray (): any[] {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -247,12 +247,12 @@ async function getFunctionValue (
|
||||
const funcImpl = control.client.getHierarchy().as(transform, serverProcess.mixin.FuncImpl)
|
||||
const f = await getResource(funcImpl.func)
|
||||
const val = await f(res, {}, control, execution)
|
||||
if (val == null) {
|
||||
if (val == null && context.func !== process.function.EmptyValue) {
|
||||
throw processError(process.error.EmptyFunctionResult, {}, { func: func.label })
|
||||
}
|
||||
return val
|
||||
}
|
||||
if (res == null) {
|
||||
if (res == null && context.func !== process.function.EmptyValue) {
|
||||
throw processError(process.error.EmptyFunctionResult, {}, { func: func.label })
|
||||
}
|
||||
return res
|
||||
|
||||
@@ -109,6 +109,7 @@ export default plugin(serverProcessId, {
|
||||
RemoveLast: '' as Resource<TransformFunc>,
|
||||
CurrentUser: '' as Resource<TransformFunc>,
|
||||
CurrentDate: '' as Resource<TransformFunc>,
|
||||
EmptyValue: '' as Resource<TransformFunc>,
|
||||
EmptyArray: '' as Resource<TransformFunc>,
|
||||
Filter: '' as Resource<TransformFunc>,
|
||||
FirstMatchValue: '' as Resource<TransformFunc>,
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
"@hcengineering/server-token": "workspace:^0.7.18",
|
||||
"@hcengineering/server-core": "workspace:^0.7.19",
|
||||
"@hcengineering/server-storage": "workspace:^0.7.16",
|
||||
"@hcengineering/api-client": "workspace:^0.7.25",
|
||||
"@hcengineering/core": "workspace:^0.7.26",
|
||||
"@hcengineering/server-guest-resources": "workspace:^0.7.0",
|
||||
"@hcengineering/platform": "workspace:^0.7.20",
|
||||
"@hcengineering/account-client": "workspace:^0.7.25",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Config {
|
||||
Port: number
|
||||
Secret: string
|
||||
AccountsUrl: string
|
||||
FrontUrl: string
|
||||
AllowedHostnames: string[]
|
||||
PuppeteerArgs: string[]
|
||||
}
|
||||
@@ -24,6 +25,7 @@ const config: Config = (() => {
|
||||
Port: parseNumber(process.env.PORT) ?? 4005,
|
||||
Secret: process.env.SECRET,
|
||||
AccountsUrl: process.env.ACCOUNTS_URL,
|
||||
FrontUrl: process.env.FRONT_URL,
|
||||
AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','),
|
||||
PuppeteerArgs: puppeteerArgs.split(',')
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//
|
||||
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverCore from '@hcengineering/server-core'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
|
||||
import { storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
@@ -12,6 +13,7 @@ import { createServer, listen } from './server'
|
||||
const setupMetadata = (): void => {
|
||||
setMetadata(serverToken.metadata.Secret, config.Secret)
|
||||
setMetadata(serverToken.metadata.Service, 'print')
|
||||
setMetadata(serverCore.metadata.FrontUrl, config.FrontUrl)
|
||||
}
|
||||
|
||||
export const main = async (): Promise<void> => {
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { generateId, newMetrics, type WorkspaceIds } from '@hcengineering/core'
|
||||
import { createRestTxOperations } from '@hcengineering/api-client'
|
||||
import { type Class, type Doc, generateId, newMetrics, type Ref, type WorkspaceIds } from '@hcengineering/core'
|
||||
import { StorageConfiguration, initStatisticsContext } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig } from '@hcengineering/server-storage'
|
||||
import { getClient as getAccountClientRaw, AccountClient, isWorkspaceLoginInfo } from '@hcengineering/account-client'
|
||||
import {
|
||||
getClient as getAccountClientRaw,
|
||||
AccountClient,
|
||||
isWorkspaceLoginInfo,
|
||||
type WorkspaceLoginInfo
|
||||
} from '@hcengineering/account-client'
|
||||
import { getPublicLink } from '@hcengineering/server-guest-resources'
|
||||
import { createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import cors from 'cors'
|
||||
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
|
||||
@@ -97,7 +104,13 @@ const extractToken = (headers: IncomingHttpHeaders, queryParams: any): string =>
|
||||
}
|
||||
}
|
||||
|
||||
type AsyncRequestHandler = (req: Request, res: Response, wsIds: WorkspaceIds, next: NextFunction) => Promise<void>
|
||||
type AsyncRequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
wsIds: WorkspaceIds,
|
||||
wsLoginInfo: WorkspaceLoginInfo,
|
||||
next: NextFunction
|
||||
) => Promise<void>
|
||||
|
||||
const handleRequest = async (
|
||||
fn: AsyncRequestHandler,
|
||||
@@ -116,7 +129,7 @@ const handleRequest = async (
|
||||
dataId: wsLoginInfo.workspaceDataId,
|
||||
url: wsLoginInfo.workspaceUrl
|
||||
}
|
||||
await fn(req, res, wsIds, next)
|
||||
await fn(req, res, wsIds, wsLoginInfo, next)
|
||||
} catch (err: unknown) {
|
||||
next(err)
|
||||
}
|
||||
@@ -127,6 +140,33 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
|
||||
handleRequest(fn, req, res, next)
|
||||
}
|
||||
|
||||
function parsePrintOptions (query: Request['query']): PrintOptions {
|
||||
const kind = query.kind as PrintOptions['kind']
|
||||
|
||||
if (kind !== undefined && !validKinds.includes(kind as any)) {
|
||||
throw new ApiError(400, `Invalid print kind: ${kind}`)
|
||||
}
|
||||
|
||||
const rawWidth = (query.width ?? '') as string
|
||||
const rawHeight = (query.height ?? '') as string
|
||||
|
||||
let viewport: PrintOptions['viewport'] | undefined
|
||||
if (rawWidth.length > 0 && rawHeight.length > 0) {
|
||||
viewport = {
|
||||
width: parseInt(rawWidth, 10),
|
||||
height: parseInt(rawHeight, 10)
|
||||
}
|
||||
|
||||
if (Number.isNaN(viewport.width) || Number.isNaN(viewport.height)) {
|
||||
throw new ApiError(400, 'Invalid width or height')
|
||||
}
|
||||
} else if (rawWidth.length > 0 || rawHeight.length > 0) {
|
||||
throw new ApiError(400, 'Both width and height must be provided')
|
||||
}
|
||||
|
||||
return { kind, viewport }
|
||||
}
|
||||
|
||||
export function createServer (
|
||||
storageConfig: StorageConfiguration,
|
||||
allowedHostnames: string[]
|
||||
@@ -154,7 +194,7 @@ export function createServer (
|
||||
|
||||
app.get(
|
||||
'/print',
|
||||
wrapRequest(async (req, res, wsIds, token) => {
|
||||
wrapRequest(async (req, res, wsIds, wsLoginInfo) => {
|
||||
const ctx = req.ctx
|
||||
const rawlink = req.query.link as string
|
||||
const link = decodeURIComponent(rawlink)
|
||||
@@ -165,36 +205,15 @@ export function createServer (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
(whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname))
|
||||
) {
|
||||
ctx.error('Rejected processing unexpected link', { link, token })
|
||||
ctx.error('Rejected processing unexpected link', { link })
|
||||
throw new ApiError(403, 'Cannot process provided link')
|
||||
}
|
||||
|
||||
const kind = req.query.kind as PrintOptions['kind']
|
||||
const options = parsePrintOptions(req.query)
|
||||
|
||||
if (kind !== undefined && !validKinds.includes(kind as any)) {
|
||||
throw new ApiError(400, `Invalid print kind: ${kind}`)
|
||||
}
|
||||
|
||||
const rawWidth = (req.query.width ?? '') as string
|
||||
const rawHeight = (req.query.height ?? '') as string
|
||||
|
||||
let viewport: PrintOptions['viewport'] | undefined
|
||||
if (rawWidth.length > 0 && rawHeight.length > 0) {
|
||||
viewport = {
|
||||
width: parseInt(rawWidth, 10),
|
||||
height: parseInt(rawHeight, 10)
|
||||
}
|
||||
|
||||
if (Number.isNaN(viewport.width) || Number.isNaN(viewport.height)) {
|
||||
throw new ApiError(400, 'Invalid width or height')
|
||||
}
|
||||
} else if (rawWidth.length > 0 || rawHeight.length > 0) {
|
||||
throw new ApiError(400, 'Both width and height must be provided')
|
||||
}
|
||||
|
||||
const printRes = await ctx.with('print', { kind }, (ctx) => print(ctx, link, { kind, viewport }), {
|
||||
const printRes = await ctx.with('print', { kind: options.kind }, (ctx) => print(ctx, link, options), {
|
||||
url,
|
||||
viewport
|
||||
viewport: options.viewport
|
||||
})
|
||||
|
||||
if (printRes === undefined) {
|
||||
@@ -203,7 +222,7 @@ export function createServer (
|
||||
|
||||
const printId = `print-${generateId()}`
|
||||
|
||||
await storageAdapter.put(ctx, wsIds, printId, printRes, `application/${kind}`, printRes.length)
|
||||
await storageAdapter.put(ctx, wsIds, printId, printRes, `application/${options.kind}`, printRes.length)
|
||||
|
||||
res.contentType('application/json')
|
||||
res.send({ id: printId })
|
||||
@@ -252,6 +271,44 @@ export function createServer (
|
||||
})
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/print/:objectClass/:objectId',
|
||||
wrapRequest(async (req, res, wsIds, wsLoginInfo) => {
|
||||
const ctx = req.ctx
|
||||
const objectId = req.params.objectId
|
||||
const objectClass = req.params.objectClass
|
||||
const options = parsePrintOptions(req.query)
|
||||
|
||||
const transactorUrl = wsLoginInfo.endpoint.replace('ws://', 'http://').replace('wss://', 'https://')
|
||||
const client = await createRestTxOperations(transactorUrl, wsLoginInfo.workspace, wsLoginInfo.token)
|
||||
|
||||
try {
|
||||
const doc = await client.findOne(objectClass as Ref<Class<Doc>>, { _id: objectId as Ref<Doc> })
|
||||
if (doc === undefined) {
|
||||
throw new ApiError(404, 'Document not found')
|
||||
}
|
||||
|
||||
const link = await getPublicLink(doc, client, wsIds, true, null)
|
||||
|
||||
const printRes = await ctx.with('print', { kind: options.kind }, (ctx) => print(ctx, link, options), {
|
||||
link,
|
||||
viewport: options.viewport
|
||||
})
|
||||
|
||||
if (printRes === undefined) {
|
||||
throw new ApiError(400, 'Failed to print')
|
||||
}
|
||||
|
||||
const kind = options.kind
|
||||
const contentType = kind === 'pdf' || kind === undefined ? 'application/pdf' : `image/${kind}`
|
||||
res.contentType(contentType)
|
||||
res.send(printRes)
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||
measureCtx.error('error', { err })
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { PlatformSetting, PlatformURI } from '../utils'
|
||||
|
||||
test.use({
|
||||
storageState: PlatformSetting
|
||||
})
|
||||
|
||||
test.describe('Customize sidebar applications', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`${PlatformURI}/workbench/sanity-ws/recruit`)
|
||||
})
|
||||
|
||||
test('sidebar apps match Customize visibility toggles', async ({ page }) => {
|
||||
const recruitSidebar = page.getByTestId('app-sidebar-recruit')
|
||||
const customize = page.getByTestId('workbench-app-customize')
|
||||
const recruitRow = page.getByTestId('app-switcher-row-recruit')
|
||||
|
||||
await expect(recruitSidebar).toBeVisible()
|
||||
|
||||
await customize.click()
|
||||
await expect(recruitRow).toBeVisible()
|
||||
await recruitRow.click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(recruitSidebar).toHaveCount(0)
|
||||
|
||||
await customize.click()
|
||||
await expect(recruitRow).toBeVisible()
|
||||
await recruitRow.click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(recruitSidebar).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
type WorkspaceToken
|
||||
} from '@hcengineering/api-client'
|
||||
import core, {
|
||||
AccountRole,
|
||||
buildSocialIdString,
|
||||
concatLink,
|
||||
generateId,
|
||||
@@ -46,8 +45,6 @@ import { type AccountClient, getClient as getAccountClient } from '@hcengineerin
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import contact, { ensureEmployee, type SocialIdentityRef, type Person } from '@hcengineering/contact'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
|
||||
import { loveClass, LoveMeetingStatus } from '../loveApiRefs'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
describe('rest-api-server', () => {
|
||||
@@ -341,104 +338,6 @@ describe('rest-api-server', () => {
|
||||
})
|
||||
}, 20000)
|
||||
})
|
||||
|
||||
/**
|
||||
* Requires a workspace account with AccountRole.Guest (invite / guest link) in the same workspace as `user1`.
|
||||
* Set API_TESTS_GUEST_EMAIL and API_TESTS_GUEST_PASSWORD to enable.
|
||||
*/
|
||||
describe('guest meeting-minutes read', () => {
|
||||
const guestEmail = 'guest1'
|
||||
const guestPassword = '1234'
|
||||
|
||||
it('guest findAll does not return meeting minutes without collaborator', async () => {
|
||||
const userConn = connect()
|
||||
const rooms = await userConn.findAll(loveClass.Room, {}, { limit: 1 })
|
||||
if (rooms.length === 0) {
|
||||
throw new Error('No love Room in workspace — cannot seed MeetingMinutes')
|
||||
}
|
||||
const room = rooms[0]
|
||||
|
||||
const tx = await connectTx()
|
||||
const title = `api-test-mm-${generateId()}`
|
||||
const mmId = await tx.createDoc(loveClass.MeetingMinutes, core.space.Workspace, {
|
||||
title,
|
||||
description: null,
|
||||
attachedTo: room._id,
|
||||
attachedToClass: loveClass.Room,
|
||||
collection: 'meetings',
|
||||
status: LoveMeetingStatus.Finished
|
||||
})
|
||||
|
||||
try {
|
||||
const userFound = await userConn.findAll(loveClass.MeetingMinutes, { _id: mmId })
|
||||
expect(userFound.length).toBe(1)
|
||||
|
||||
const guestWs = await getWorkspaceToken(
|
||||
'http://huly.local:8083',
|
||||
{
|
||||
email: guestEmail,
|
||||
password: guestPassword,
|
||||
workspace: wsName
|
||||
},
|
||||
serverConfig
|
||||
)
|
||||
expect(guestWs.info.role).toBe(AccountRole.Guest)
|
||||
|
||||
const guestConn = createRestClient(guestWs.endpoint, guestWs.workspaceId, guestWs.token)
|
||||
const guestFound = await guestConn.findAll(loveClass.MeetingMinutes, { _id: mmId })
|
||||
expect(guestFound.length).toBe(0)
|
||||
} finally {
|
||||
await tx.removeDoc(loveClass.MeetingMinutes, core.space.Workspace, mmId)
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
it('guest findAll returns meeting minutes when guest is a Collaborator', async () => {
|
||||
const userConn = connect()
|
||||
const rooms = await userConn.findAll(loveClass.Room, {}, { limit: 1 })
|
||||
if (rooms.length === 0) {
|
||||
throw new Error('No love Room in workspace — cannot seed MeetingMinutes')
|
||||
}
|
||||
const room = rooms[0]
|
||||
|
||||
const guestWs = await getWorkspaceToken(
|
||||
'http://huly.local:8083',
|
||||
{
|
||||
email: guestEmail,
|
||||
password: guestPassword,
|
||||
workspace: wsName
|
||||
},
|
||||
serverConfig
|
||||
)
|
||||
expect(guestWs.info.role).toBe(AccountRole.Guest)
|
||||
|
||||
const tx = await connectTx()
|
||||
const title = `api-test-mm-${generateId()}`
|
||||
const mmId = await tx.createDoc(loveClass.MeetingMinutes, core.space.Workspace, {
|
||||
title,
|
||||
description: null,
|
||||
attachedTo: room._id,
|
||||
attachedToClass: loveClass.Room,
|
||||
collection: 'meetings',
|
||||
status: LoveMeetingStatus.Finished
|
||||
})
|
||||
|
||||
const collabId = await tx.createDoc(core.class.Collaborator, core.space.Workspace, {
|
||||
attachedTo: mmId,
|
||||
attachedToClass: loveClass.MeetingMinutes,
|
||||
collection: 'collaborators',
|
||||
collaborator: guestWs.info.account
|
||||
} as any)
|
||||
|
||||
try {
|
||||
const guestConn = createRestClient(guestWs.endpoint, guestWs.workspaceId, guestWs.token)
|
||||
const guestFound = await guestConn.findAll(loveClass.MeetingMinutes, { _id: mmId })
|
||||
expect(guestFound.length).toBe(1)
|
||||
} finally {
|
||||
await tx.removeDoc(core.class.Collaborator, core.space.Workspace, collabId)
|
||||
await tx.removeDoc(loveClass.MeetingMinutes, core.space.Workspace, mmId)
|
||||
}
|
||||
}, 60000)
|
||||
})
|
||||
})
|
||||
|
||||
async function checkFindPerformance (conn: RestClient): Promise<void> {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// Class refs for the love plugin — same id shape the model builder emits (`${pluginId}:class:${name}`).
|
||||
// Use this in API tests without a dependency on `@hcengineering/love`.
|
||||
//
|
||||
|
||||
import type { Class, Doc, Ref } from '@hcengineering/core'
|
||||
|
||||
export const loveClass = {
|
||||
Room: 'love:class:Room' as Ref<Class<Doc>>,
|
||||
MeetingMinutes: 'love:class:MeetingMinutes' as Ref<Class<Doc>>
|
||||
}
|
||||
|
||||
/** @see `@hcengineering/love` MeetingStatus */
|
||||
export enum LoveMeetingStatus {
|
||||
Active = 0,
|
||||
Finished = 1
|
||||
}
|
||||
@@ -32,7 +32,6 @@ echo "Creating user accounts..."
|
||||
./tool.sh create-account admin -f Super -l Admin -p 1234
|
||||
./tool.sh create-account user1 -f John -l Appleseed -p 1234
|
||||
./tool.sh create-account user2 -f Kainin -l Dirak -p 1234
|
||||
./tool.sh create-account guest1 -f Guest -l One -p 1234
|
||||
|
||||
echo "Creating workspace api-tests..."
|
||||
./tool.sh create-workspace api-tests email:user1
|
||||
@@ -44,8 +43,4 @@ echo "Assigning user1 to workspaces..."
|
||||
./tool.sh assign-workspace user1 api-tests
|
||||
./tool.sh assign-workspace user1 api-tests-cr
|
||||
|
||||
echo "Assigning guest1 to api-tests as Guest..."
|
||||
./tool.sh assign-workspace guest1 api-tests --role Guest
|
||||
./tool.sh assign-workspace guest1 api-tests-cr --role Guest
|
||||
|
||||
rm -rf ./sanity/.auth
|
||||
|
||||
Reference in New Issue
Block a user