diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2e4df4f0693..753708009e2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -968,6 +968,8 @@ jobs: run: node common/scripts/install-run-rush.js install - name: Building... run: node common/scripts/install-run-rush.js build + - name: Emit TypeScript declarations + run: node common/scripts/install-run-rush.js validate - name: Publish to npm env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index de433b45da8..5437a5f45b7 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -38292,6 +38292,9 @@ importers: '@hcengineering/postgres': specifier: workspace:^0.7.22 version: link:../../../foundations/server/packages/postgres + '@hcengineering/products': + specifier: workspace:^0.7.0 + version: link:../../../plugins/products '@hcengineering/server-client': specifier: workspace:^0.7.16 version: link:../../../foundations/server/packages/client diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000000..fe22bbfba9f --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,456 @@ +openapi: 3.1.0 +info: + title: Huly Self-Hosted API + version: 0.1.0 + description: | + REST API for Huly self-hosted instances. + + ## Authentication + + All data endpoints require a workspace-scoped JWT in the `Authorization: Bearer ` header. + Tokens can be minted using the CLI tool (`tools/mint-token/`) or the optional token service (`/_tokens`). + + ## Getting Started + + 1. Mint a token using the CLI tool or token service + 2. Use the token to query the transactor REST API + 3. The `find-all` endpoint queries documents by class + 4. The `tx` endpoint submits transactions (create, update, delete) + + license: + name: EPL-2.0 + url: https://www.eclipse.org/legal/epl-2.0/ + +servers: + - url: https://{host} + description: Your Huly self-hosted instance + variables: + host: + default: localhost:8083 + +security: + - bearerAuth: [] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + Workspace-scoped JWT. Payload: `{ account: AccountUuid, workspace: WorkspaceUuid, exp: number }`. + Signed with `SERVER_SECRET` using HS256. + + serverSecret: + type: apiKey + in: header + name: X-Server-Secret + description: SERVER_SECRET value — used for admin-only token service endpoints. + + schemas: + TxOperation: + type: object + description: A Huly transaction object + properties: + _class: + type: string + description: Transaction class (e.g. `core:class:TxCreateDoc`) + example: 'core:class:TxCreateDoc' + objectClass: + type: string + description: Target document class + example: 'tracker:class:Issue' + objectSpace: + type: string + description: Target space + example: 'tracker:project:DefaultProject' + objectId: + type: string + description: Document ID (generated or existing) + attributes: + type: object + description: Document attributes to set + required: + - _class + + FindAllResponse: + type: object + properties: + result: + type: array + items: + type: object + description: Array of matching documents + total: + type: integer + description: Total count of matching documents + + TokenRequest: + type: object + properties: + email: + type: string + format: email + description: User email — resolved to account UUID via CockroachDB + example: user@example.com + workspace: + type: string + description: Workspace URL slug — resolved to workspace UUID via CockroachDB + example: my-workspace + expiryDays: + type: integer + minimum: 1 + maximum: 365 + default: 30 + description: Token lifetime in days + required: + - email + - workspace + + TokenResponse: + type: object + properties: + id: + type: string + description: Token metadata ID (for listing/revoking) + token: + type: string + description: Signed JWT + expiresAt: + type: string + format: date-time + description: Token expiration timestamp + + TokenMetadata: + type: object + properties: + id: + type: string + email: + type: string + workspace: + type: string + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + revoked: + type: boolean + + JsonRpcRequest: + type: object + properties: + method: + type: string + description: RPC method name + params: + type: array + items: {} + description: Method parameters + required: + - method + - params + + JsonRpcResponse: + type: object + properties: + id: + type: string + result: + description: Method-specific result + + Error: + type: object + properties: + error: + type: string + +paths: + # ── Account Service (JSON-RPC) ────────────────────────────────────── + + /_accounts: + post: + operationId: accountRpc + tags: [Auth] + summary: Account service JSON-RPC + description: | + JSON-RPC endpoint for the account service. Key methods: + + - `login(params: [email, password])` → returns account JWT + - `selectWorkspace(params: [workspaceUrl, kind, allowAdmin])` → returns workspace JWT + endpoint + - `getUserWorkspaces()` → list workspaces for authenticated user + + Note: For API token-based access, you don't need these — mint a token directly. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcRequest' + examples: + login: + summary: Login + value: + method: login + params: ['user@example.com', 'password'] + selectWorkspace: + summary: Select workspace + value: + method: selectWorkspace + params: ['my-workspace', 'external', false] + responses: + '200': + description: JSON-RPC response + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcResponse' + '400': + description: Malformed request + + # ── Transactor REST API ───────────────────────────────────────────── + + /_transactor/api/v1/find-all/{workspace}: + get: + operationId: findAll + tags: [Data] + summary: Query documents by class + description: | + Find all documents matching a class and optional query/options filters. + Returns an array of matching documents. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + example: 'ws-abc-123' + - name: class + in: query + required: true + schema: + type: string + description: Huly class reference + example: 'contact:class:Person' + - name: query + in: query + schema: + type: string + description: JSON-encoded query filter + example: '{"name": "John"}' + - name: options + in: query + schema: + type: string + description: JSON-encoded options (limit, sort, etc.) + example: '{"limit": 10}' + responses: + '200': + description: Matching documents + content: + application/json: + schema: + $ref: '#/components/schemas/FindAllResponse' + '401': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /_transactor/api/v1/tx/{workspace}: + post: + operationId: submitTx + tags: [Data] + summary: Submit a transaction + description: | + Submit a transaction to create, update, or delete documents. + The transaction format follows the Huly platform transaction model. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TxOperation' + examples: + createIssue: + summary: Create a tracker issue + value: + _class: 'core:class:TxCreateDoc' + objectClass: 'tracker:class:Issue' + objectSpace: 'tracker:project:DefaultProject' + attributes: + title: 'Fix login bug' + description: 'Users cannot log in with SSO' + priority: 1 + responses: + '200': + description: Transaction result + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + /_transactor/api/v1/load-model/{workspace}: + get: + operationId: loadModel + tags: [Data] + summary: Load data model / schema + description: | + Returns the full data model (class hierarchy, mixins, attributes) for the workspace. + Useful for discovering available classes and their fields. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Data model + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + /_transactor/api/v1/ping/{workspace}: + get: + operationId: ping + tags: [Data] + summary: Health check + description: | + Verifies the token is valid and the workspace is reachable. + Returns a simple status response. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Workspace is reachable + content: + application/json: + schema: + type: object + properties: + pong: + type: boolean + '401': + description: Invalid or expired token + + /_transactor/api/v1/account/{workspace}: + get: + operationId: getAccountInfo + tags: [Data] + summary: Get account info + description: | + Returns information about the authenticated account in the context of the given workspace, + including the account UUID and workspace role. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Account information + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + # ── Token Service (optional) ──────────────────────────────────────── + + /_tokens: + post: + operationId: mintToken + tags: [Tokens] + summary: Mint an API token + description: | + Mint a workspace-scoped JWT. Requires admin auth via `X-Server-Secret` header. + Resolves email → account UUID and workspace slug → workspace UUID automatically. + + Only available when `tokenService.enabled: true` in Helm values. + security: + - serverSecret: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: Minted token + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + '401': + description: Invalid server secret + '404': + description: Email or workspace not found + + get: + operationId: listTokens + tags: [Tokens] + summary: List minted tokens + description: Returns metadata for all minted tokens (token values are not stored). + security: + - serverSecret: [] + responses: + '200': + description: Token list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TokenMetadata' + '401': + description: Invalid server secret + + /_tokens/{id}: + delete: + operationId: revokeToken + tags: [Tokens] + summary: Revoke a token + description: | + Soft-revoke a token by marking it as revoked in the database. + Note: The token will still be valid until it expires, as full revocation + requires a denylist check at the transactor level (future enhancement). + security: + - serverSecret: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Token revoked + '404': + description: Token not found diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 1a8e6ea4308..49253203c6d 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -35,6 +35,8 @@ import { import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import type { AccountAggregatedInfo, + ApiTokenInfo, + ApiTokenResult, Integration, IntegrationKey, IntegrationSecret, @@ -55,6 +57,7 @@ import type { Subscription, SubscriptionData, UserProfile, + WorkspaceConfiguration, WorkspaceLoginInfo, WorkspaceOperation } from './types' @@ -134,7 +137,11 @@ export interface AccountClient { getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise updateLastVisit: (workspaces: WorkspaceUuid[]) => Promise getRegionInfo: () => Promise - createWorkspace: (name: string, region?: string) => Promise + createWorkspace: ( + name: string, + region?: string, + configuration?: WorkspaceConfiguration + ) => Promise signUpOtp: (email: string, first: string, last: string) => Promise /** * Deprecated. Only to be used for dev setups without mail service. @@ -237,7 +244,7 @@ export interface AccountClient { addEmailSocialId: (email: string) => Promise addHulyAssistantSocialId: () => Promise refreshHulyAssistantToken: () => Promise - updatePasswordAgingRule: (days: number) => Promise + updatePasswordAgingRule: (days?: number) => Promise checkPasswordAging: () => Promise setMyProfile: (profile: Partial>) => Promise @@ -255,6 +262,16 @@ export interface AccountClient { getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise verify2fa: (code: string) => Promise + createApiToken: ( + name: string, + workspaceUuid: WorkspaceUuid, + expiryDays: number, + scopes?: string[] + ) => Promise + listApiTokens: () => Promise + revokeApiToken: (tokenId: string) => Promise + listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise + revokeWorkspaceApiToken: (tokenId: string, workspaceUuid: WorkspaceUuid) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -545,7 +562,7 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } - async updatePasswordAgingRule (days: number): Promise { + async updatePasswordAgingRule (days?: number): Promise { const request = { method: 'updatePasswordAgingRule' as const, params: { days } @@ -668,10 +685,14 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } - async createWorkspace (workspaceName: string, region?: string): Promise { + async createWorkspace ( + workspaceName: string, + region?: string, + configuration?: WorkspaceConfiguration + ): Promise { const request = { method: 'createWorkspace' as const, - params: { workspaceName, region } + params: { workspaceName, region, configuration } } return await this.rpc(request) @@ -1224,6 +1245,56 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + async createApiToken ( + name: string, + workspaceUuid: WorkspaceUuid, + expiryDays: number, + scopes?: string[] + ): Promise { + const request = { + method: 'createApiToken' as const, + params: { name, workspaceUuid, expiryDays, scopes } + } + + return await this.rpc(request) + } + + async listApiTokens (): Promise { + const request = { + method: 'listApiTokens' as const, + params: {} + } + + return await this.rpc(request) + } + + async revokeApiToken (tokenId: string): Promise { + const request = { + method: 'revokeApiToken' as const, + params: { tokenId } + } + + await this.rpc(request) + } + + async listWorkspaceApiTokens (workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'listWorkspaceApiTokens' as const, + params: { workspaceUuid } + } + + return await this.rpc(request) + } + + async revokeWorkspaceApiToken (tokenId: string, workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'revokeWorkspaceApiToken' as const, + params: { tokenId, workspaceUuid } + } + + await this.rpc(request) + } + async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index c9f791f3c21..a308a5c3eeb 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -14,6 +14,8 @@ import { IntegrationKind } from '@hcengineering/core' +export type { WorkspaceConfiguration } from '@hcengineering/core' + export interface LoginInfo { account: AccountUuid name?: string @@ -43,7 +45,7 @@ export interface LoginInfoWorkspace { role: AccountRole | null progress?: number branding?: string - passwordAgingRule?: number // in days + passwordAgingRule?: number | null // in days } export interface LoginInfoWithWorkspaces extends LoginInfo { @@ -112,6 +114,23 @@ export interface MailboxInfo { appPasswords: string[] } +export interface ApiTokenInfo { + id: string + name: string + workspaceUuid: WorkspaceUuid + workspaceName: string + createdOn: number + expiresOn: number + revoked: boolean + scopes?: string[] +} + +export interface ApiTokenResult { + id: string + token: string + expiresOn: number +} + export interface MailboxSecret { mailbox: string app?: string diff --git a/foundations/core/packages/client-resources/src/connection.ts b/foundations/core/packages/client-resources/src/connection.ts index 0309ad7abe6..0b3b44c7720 100644 --- a/foundations/core/packages/client-resources/src/connection.ts +++ b/foundations/core/packages/client-resources/src/connection.ts @@ -777,7 +777,7 @@ class Connection implements ClientConnection { params: [_class, query, options], measure: (time, result, serverTime, queue, toReceive) => { if (typeof window !== 'undefined' && (time > 1000 || serverTime > 500)) { - console.error( + console.warn( 'measure slow findAll', time, serverTime, diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index c9db96401ad..0cd0c5080be 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -899,6 +899,25 @@ export type WorkspaceUpdateEvent = | 'delete-started' | 'delete-done' +/** + * Initial-state configuration captured at workspace creation. Currently only + * carries whether the workspace should be populated with demo content. Lives + * on `WorkspaceInfo.pendingConfiguration` until consumed by workspace-service + * after model init, then cleared back to `null`. + * + * Kept as a struct (rather than a bare boolean) so future opt-in fields can + * be added without breaking the wire format. + * + * @public + */ +export interface WorkspaceConfiguration { + /** + * Whether to run the workspace init script (sample projects and other demo content). + * Defaults to `true` on the server side to preserve legacy behavior. + */ + withDemoContent?: boolean +} + export interface WorkspaceInfo { uuid: WorkspaceUuid dataId?: WorkspaceDataId // Old workspace identifier. E.g. Database name in Mongo, bucket in R2, etc. @@ -911,7 +930,10 @@ export interface WorkspaceInfo { billingAccount?: PersonUuid // Should always be set for NEW workspaces allowReadOnlyGuest?: boolean // Should always be set for NEW workspaces allowGuestSignUp?: boolean // Should always be set for NEW workspaces - passwordAgingRule?: number // in days + passwordAgingRule?: number | null // in days + // Initial-state configuration set by the user at workspace creation. Read once + // by workspace-service after model init, then cleared back to `null`. + pendingConfiguration?: WorkspaceConfiguration | null } export interface BackupStatus { diff --git a/foundations/core/packages/core/src/hierarchy.ts b/foundations/core/packages/core/src/hierarchy.ts index 9b8fc3665ce..32d055c7a40 100644 --- a/foundations/core/packages/core/src/hierarchy.ts +++ b/foundations/core/packages/core/src/hierarchy.ts @@ -162,6 +162,23 @@ export class Hierarchy { return Array.from(resultSet) } + getAllPossibleMixins (_class: Ref>, to: Ref> = core.class.Doc): Ref>[] { + const result = new Set>>() + let c = this.getClass(_class) + + while (c._id !== to && c.extends !== undefined) { + const _descendants = this.descendants.get(c._id) + for (const d of _descendants ?? []) { + if (this.isMixin(d) && this.getBaseClass(d) === c._id) { + result.add(d) + } + } + c = this.getClass(c.extends) + } + + return [...result] + } + isMixin (_class: Ref>): boolean { const data = this.classifiers.get(_class) return data !== undefined && this._isMixin(data) diff --git a/foundations/core/packages/core/src/versioning.ts b/foundations/core/packages/core/src/versioning.ts index 34db2fd7bba..6ab64893a5f 100644 --- a/foundations/core/packages/core/src/versioning.ts +++ b/foundations/core/packages/core/src/versioning.ts @@ -1,4 +1,4 @@ -import { Class, Doc, PersonId, Ref } from './classes' +import { Class, Doc, Mixin, PersonId, Ref } from './classes' export interface VersionableDoc extends Doc { baseId?: Ref @@ -10,4 +10,7 @@ export interface VersionableDoc extends Doc { export interface VersionableClass extends Class { enabled: boolean + excludedProperties?: string[] + excludedRelations?: string[] // ${associationId}_${a|b} + excludeMixins?: Ref>[] } diff --git a/foundations/core/packages/token/src/__tests__/token.test.ts b/foundations/core/packages/token/src/__tests__/token.test.ts index fc54e62991f..9d842423261 100644 --- a/foundations/core/packages/token/src/__tests__/token.test.ts +++ b/foundations/core/packages/token/src/__tests__/token.test.ts @@ -15,7 +15,7 @@ import { setMetadata } from '@hcengineering/platform' import type { PersonUuid, WorkspaceUuid } from '@hcengineering/core' -import { decodeToken, generateToken } from '../token' +import { decodeToken, generateToken, isTokenExpired, setApiTokenRevocationChecker, verifyToken } from '../token' import plugin from '../plugin' export function decodeTokenPayload (token: string): any { @@ -114,3 +114,74 @@ describe('generateToken', () => { }) }) }) + +const ACCOUNT = '123e4567-e89b-12d3-a456-426614174000' as PersonUuid +const WORKSPACE = '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid + +describe('isTokenExpired', () => { + it('is false when exp is absent', () => { + expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE })).toBe(false) + }) + + it('is false when exp is in the future', () => { + const exp = Math.floor(Date.now() / 1000) + 3600 + expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE, exp })).toBe(false) + }) + + it('is true when exp is in the past', () => { + const exp = Math.floor(Date.now() / 1000) - 1 + expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE, exp })).toBe(true) + }) +}) + +describe('verifyToken', () => { + beforeEach(() => { + setMetadata(plugin.metadata.Secret, undefined) + setMetadata(plugin.metadata.Service, undefined) + setApiTokenRevocationChecker(undefined) + }) + + afterAll(() => { + setApiTokenRevocationChecker(undefined) + }) + + it('returns the decoded token for a valid, non-expiring token', async () => { + const token = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret') + const decoded = await verifyToken(token, 'secret') + expect(decoded.account).toBe(ACCOUNT) + expect(decoded.workspace).toBe(WORKSPACE) + }) + + it('throws for an expired token', async () => { + const exp = Math.floor(Date.now() / 1000) - 1 + const token = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret', { exp }) + await expect(verifyToken(token, 'secret')).rejects.toThrow('Token expired') + }) + + it('skips revocation when no checker is registered', async () => { + const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-1' }, 'secret') + const decoded = await verifyToken(token, 'secret') + expect(decoded.extra?.apiTokenId).toBe('tok-1') + }) + + it('throws when the registered checker reports the API token revoked', async () => { + setApiTokenRevocationChecker(async () => true) + const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-revoked' }, 'secret') + await expect(verifyToken(token, 'secret')).rejects.toThrow('Token revoked') + }) + + it('only invokes the checker for revokable (API) tokens', async () => { + let calls = 0 + setApiTokenRevocationChecker(async () => { + calls++ + return false + }) + const plain = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret') + await verifyToken(plain, 'secret') + expect(calls).toBe(0) + + const api = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-2' }, 'secret') + await verifyToken(api, 'secret') + expect(calls).toBe(1) + }) +}) diff --git a/foundations/core/packages/token/src/token.ts b/foundations/core/packages/token/src/token.ts index 7a6ae0eabf5..04e8d232fe7 100644 --- a/foundations/core/packages/token/src/token.ts +++ b/foundations/core/packages/token/src/token.ts @@ -138,3 +138,75 @@ export function decodeTokenVerbose (ctx: MeasureContext, token: string): Token { throw new TokenError(err.message) } } + +/** + * Checks whether a token has passed its `exp` (seconds since epoch) deadline. + * `decodeToken` only verifies the signature — expiry must be checked separately. + * @public + */ +export function isTokenExpired (token: Token, now: number = Date.now()): boolean { + return token.exp !== undefined && token.exp * 1000 <= now +} + +/** + * Resolves whether a revokable API token (identified by `extra.apiTokenId`) + * has been revoked. Registered by services that can reach the account + * (see {@link setApiTokenRevocationChecker}); other services skip the check. + * @public + */ +export type ApiTokenRevocationChecker = (apiTokenId: string, token: Token, raw: string) => Promise + +let apiTokenRevocationChecker: ApiTokenRevocationChecker | undefined + +const REVOCATION_CACHE_TTL_MS = 60_000 +const revocationCache = new Map() + +/** + * Registers the revocation resolver used by {@link verifyToken}. Services with + * an account client install this once at startup; this is the "method to verify" + * metadata the token plugin needs to enforce revocation without depending on the + * account client directly. + * @public + */ +export function setApiTokenRevocationChecker (checker: ApiTokenRevocationChecker | undefined): void { + apiTokenRevocationChecker = checker + revocationCache.clear() +} + +async function isApiTokenRevoked (apiTokenId: string, token: Token, raw: string, now: number): Promise { + const cached = revocationCache.get(apiTokenId) + // Revocation is irreversible — once confirmed it stays cached. + if (cached?.revoked === true) return true + if (cached === undefined || now - cached.checkedAt > REVOCATION_CACHE_TTL_MS) { + try { + const revoked = await (apiTokenRevocationChecker as ApiTokenRevocationChecker)(apiTokenId, token, raw) + revocationCache.set(apiTokenId, { revoked, checkedAt: now }) + return revoked + } catch { + // Account unreachable: fall back to the stale verdict (fail-open) and retry next TTL. + return cached?.revoked ?? false + } + } + return cached.revoked +} + +/** + * Decodes and fully validates a token: signature (via {@link decodeToken}), + * expiry, and — for revokable API tokens — revocation. Reuse this instead of + * `decodeToken` anywhere expired or revoked tokens must be rejected (transactor + * REST API, blob access, etc.) so the policy lives in one place. + * @public + */ +export async function verifyToken (token: string, secret?: string): Promise { + const decoded = decodeToken(token, true, secret) + if (isTokenExpired(decoded)) { + throw new TokenError('Token expired') + } + const apiTokenId = decoded.extra?.apiTokenId + if (apiTokenId !== undefined && apiTokenRevocationChecker !== undefined) { + if (await isApiTokenRevoked(apiTokenId, decoded, token, Date.now())) { + throw new TokenError('Token revoked') + } + } + return decoded +} diff --git a/foundations/server/packages/server/src/sessionManager.ts b/foundations/server/packages/server/src/sessionManager.ts index 0b97765794b..55dfa351f84 100644 --- a/foundations/server/packages/server/src/sessionManager.ts +++ b/foundations/server/packages/server/src/sessionManager.ts @@ -766,7 +766,7 @@ export class TSessionManager implements SessionManager { this.workspaceInfoCache.delete(token.workspace) } - if (wsInfo.passwordAgingRule !== undefined && wsInfo.passwordAgingRule > 0) { + if (wsInfo.passwordAgingRule != null && wsInfo.passwordAgingRule > 0) { const isPasswordAgingOk = await this.checkPasswordAging(ctx, rawToken) if (!isPasswordAgingOk) { return { error: new Status(Severity.ERROR, platform.status.PasswordExpired, {}), terminate: true } diff --git a/models/all/src/index.ts b/models/all/src/index.ts index e41b28a9da4..e33dfc98abd 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -103,7 +103,7 @@ import documents, { documentsId, createModel as documentsModel } from '@hcengine import { hulyMailId, createModel as hulyMailModel } from '@hcengineering/model-huly-mail' import { mailId, createModel as mailModel } from '@hcengineering/model-mail' import products, { productsId, createModel as productsModel } from '@hcengineering/model-products' -import { questionsId, createModel as questionsModel } from '@hcengineering/model-questions' +import questions, { questionsId, createModel as questionsModel } from '@hcengineering/model-questions' import { serverProductsId, createModel as serverProductsModel } from '@hcengineering/model-server-products' import { serverTrainingId, createModel as serverTrainingModel } from '@hcengineering/model-server-training' import testManagement, { @@ -246,6 +246,7 @@ export default function buildModel (): Builder { description: telegram.string.ConfigDescription, enabled: true, beta: true, + icon: contact.icon.Telegram, classFilter: defaultFilter } ], @@ -269,6 +270,7 @@ export default function buildModel (): Builder { description: gmail.string.ConfigDescription, enabled: true, beta: true, + icon: contact.icon.Email, classFilter: defaultFilter } ], @@ -420,6 +422,7 @@ export default function buildModel (): Builder { description: documents.string.ConfigDescription, enabled: false, beta: false, + icon: documents.icon.DocumentApplication, classFilter: defaultFilter } ], @@ -427,10 +430,11 @@ export default function buildModel (): Builder { questionsModel, questionsId, { - label: setting.string.Configure, + label: questions.string.ConfigLabel, + description: questions.string.ConfigDescription, enabled: true, beta: false, - hidden: false, + icon: questions.icon.Question, classFilter: defaultFilter } ], @@ -442,6 +446,7 @@ export default function buildModel (): Builder { description: trainings.string.ConfigDescription, enabled: false, beta: false, + icon: trainings.icon.TrainingApplication, classFilter: defaultFilter } ], @@ -453,6 +458,7 @@ export default function buildModel (): Builder { description: products.string.ConfigDescription, enabled: false, beta: false, + icon: products.icon.ProductsApplication, classFilter: defaultFilter } ], @@ -464,6 +470,7 @@ export default function buildModel (): Builder { description: testManagement.string.ConfigDescription, enabled: true, beta: true, + icon: testManagement.icon.TestManagementApplication, classFilter: defaultFilter } ], @@ -475,6 +482,7 @@ export default function buildModel (): Builder { description: survey.string.ConfigDescription, enabled: false, beta: true, + icon: survey.icon.Survey, classFilter: defaultFilter } ], diff --git a/models/card/src/actions.ts b/models/card/src/actions.ts index c948a572a83..436b479eb06 100644 --- a/models/card/src/actions.ts +++ b/models/card/src/actions.ts @@ -264,7 +264,13 @@ export function createActions (builder: Builder): void { createAction( builder, { - action: card.actionImpl.DuplicateCard, + action: view.actionImpl.ShowPopup, + actionProps: { + component: card.component.DuplicateCard, + fillProps: { + _object: 'value' + } + }, label: card.string.Duplicate, icon: card.icon.Duplicate, input: 'focus', diff --git a/models/card/src/index.ts b/models/card/src/index.ts index 14c76397ebf..fbb4d05c004 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -12,7 +12,6 @@ // limitations under the License. import activity from '@hcengineering/activity' -import communication from '@hcengineering/communication' import { type CanCreateCardResource, type Card, @@ -23,6 +22,7 @@ import { type CardViewDefaults, type CreateCardExtension, DOMAIN_CARD, + type DuplicateSetting, type ExportExtension, type ExportFunc, type FavoriteCard, @@ -34,6 +34,8 @@ import { type Tag } from '@hcengineering/card' import chunter from '@hcengineering/chunter' +import communication from '@hcengineering/communication' +import converter from '@hcengineering/converter' import core, { AccountRole, type Blobs, @@ -46,6 +48,7 @@ import core, { DOMAIN_SPACE, IndexKind, type MarkupBlobRef, + type Mixin as MixinType, type MixinData, type Rank, type Ref, @@ -77,7 +80,7 @@ import presentation from '@hcengineering/model-presentation' import setting from '@hcengineering/model-setting' import view, { type Viewlet } from '@hcengineering/model-view' import workbench, { WidgetType } from '@hcengineering/model-workbench' -import converter from '@hcengineering/converter' +import notification from '@hcengineering/notification' import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform' import time, { type ToDo } from '@hcengineering/time' import { PaletteColorIndexes } from '@hcengineering/ui/src/colors' @@ -86,7 +89,6 @@ import { type BuildModelKey, type ViewOptionModel } from '@hcengineering/view' import { createActions } from './actions' import { defineActionPermissions, definePermissions } from './permissions' import card from './plugin' -import notification from '@hcengineering/notification' export { cardId } from '@hcengineering/card' @@ -223,6 +225,13 @@ export class TExportExtension extends TDoc implements ExportExtension { func!: Resource } +@Mixin(card.mixin.DuplicateSetting, card.class.MasterTag) +export class TDuplicateSetting extends TMasterTag implements DuplicateSetting { + excludedProperties?: string[] + excludedRelations?: string[] // ${associationId}_${a|b} + excludeMixins?: Ref>[] +} + export * from './migration' const showAllVersionsOption: ViewOptionModel = { @@ -441,7 +450,8 @@ export function createModel (builder: Builder): void { TFavoriteCard, TFavoriteType, TCreateCardExtension, - TExportExtension + TExportExtension, + TDuplicateSetting ) builder.createDoc( diff --git a/models/card/src/plugin.ts b/models/card/src/plugin.ts index 74a3e31fefe..ee8e754b190 100644 --- a/models/card/src/plugin.ts +++ b/models/card/src/plugin.ts @@ -30,7 +30,6 @@ export default mergeIds(cardId, card, { }, actionImpl: { DeleteMasterTag: '' as ViewAction, - DuplicateCard: '' as ViewAction, EditSpace: '' as ViewAction, CreateChild: '' as ViewAction }, @@ -39,7 +38,7 @@ export default mergeIds(cardId, card, { SetParent: '' as Ref>, UnsetParent: '' as Ref>, PublicLink: '' as Ref>, - Duplicate: '' as Ref, + Duplicate: '' as Ref>, CreateChild: '' as Ref }, category: { diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 02e69d551bf..f0feb131665 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -447,4 +447,7 @@ export class TCollaborator extends TAttachedDoc implements Collaborator { @MMixin(core.mixin.VersionableClass, core.class.Class) export class TVersionableClass extends TClass implements VersionableClass { enabled!: boolean + excludedProperties?: string[] + excludedRelations?: string[] // ${associationId}_${a|b} + excludeMixins?: Ref>[] } diff --git a/models/process/src/functions.ts b/models/process/src/functions.ts index ba7c6f932f3..11d982c12c8 100644 --- a/models/process/src/functions.ts +++ b/models/process/src/functions.ts @@ -600,6 +600,19 @@ export function defineFunctions (builder: Builder): void { process.function.StringFromNumber ) + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeIdentifier, + to: core.class.TypeString, + category: 'attribute', + label: process.string.TextFromIdentifier, + type: 'convert' + }, + process.function.StringFromIdentifier + ) + builder.createDoc( process.class.ProcessFunction, core.space.Model, diff --git a/models/products/src/index.ts b/models/products/src/index.ts index 8951fb52c9e..bfd8efe5858 100644 --- a/models/products/src/index.ts +++ b/models/products/src/index.ts @@ -254,6 +254,21 @@ function defineProduct (builder: Builder): void { builder.mixin(products.class.Product, core.class.Class, view.mixin.IgnoreActions, { actions: [tracker.action.NewRelatedIssue] }) + + createAction( + builder, + { + action: products.actionImpl.CreateProductVersion, + label: products.string.CreateProductVersion, + icon: products.icon.ProductVersion, + visibilityTester: products.function.CanCreateProductVersion, + category: view.category.General, + input: 'focus', + target: products.class.Product, + context: { mode: ['context', 'browser'], group: 'create' } + }, + products.action.CreateProductVersion + ) } function defineSpaceType (builder: Builder): void { diff --git a/models/products/src/plugin.ts b/models/products/src/plugin.ts index 64d09299066..aa0463d6afe 100644 --- a/models/products/src/plugin.ts +++ b/models/products/src/plugin.ts @@ -19,12 +19,16 @@ import products from '@hcengineering/products-resources/src/plugin' import type { Client, Doc, Ref, Role } from '@hcengineering/core' import { type Resource, mergeIds } from '@hcengineering/platform' import { type AnyComponent } from '@hcengineering/ui/src/types' -import type { Action } from '@hcengineering/view' +import type { Action, ViewAction } from '@hcengineering/view' export default mergeIds(productsId, products, { action: { + CreateProductVersion: '' as Ref>, DeleteProductVersion: '' as Ref> }, + actionImpl: { + CreateProductVersion: '' as ViewAction + }, ids: { ModulePermissionGroup: '' as Ref, ModulePermissionGroupReadOnlyGuest: '' as Ref @@ -44,6 +48,7 @@ export default mergeIds(productsId, products, { ProductVersionVersionPresenter: '' as AnyComponent }, function: { + CanCreateProductVersion: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanDeleteProductVersion: '' as Resource<(doc?: Doc | Doc[]) => Promise>, ProductIdentifierProvider: '' as Resource<(client: Client, ref: Ref, doc?: T) => Promise> }, diff --git a/models/server-process/src/index.ts b/models/server-process/src/index.ts index dafae6227bf..334528e3c0e 100644 --- a/models/server-process/src/index.ts +++ b/models/server-process/src/index.ts @@ -207,6 +207,10 @@ export function createModel (builder: Builder): void { func: serverProcess.transform.DateFromNumber }) + builder.mixin(process.function.StringFromIdentifier, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.StringFromIdentifier + }) + builder.mixin(process.function.NumberFromString, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { func: serverProcess.transform.NumberFromString }) diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index c063d87aa4b..24237d3afc7 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -440,6 +440,19 @@ export function createModel (builder: Builder): void { setting.ids.OfficeSettings ) + builder.createDoc( + setting.class.WorkspaceSettingCategory, + core.space.Model, + { + name: 'apiTokens', + label: setting.string.ApiTokens, + icon: setting.icon.ApiToken, + component: setting.component.ApiTokens, + order: 1050, + role: AccountRole.Owner + }, + setting.ids.ApiTokens + ) // Currently remove Support item from settings // builder.createDoc( // setting.class.SettingsCategory, diff --git a/packages/presentation/lang/cs.json b/packages/presentation/lang/cs.json index 83ff9d63001..ce569b48110 100644 --- a/packages/presentation/lang/cs.json +++ b/packages/presentation/lang/cs.json @@ -56,7 +56,8 @@ "LineTool": "Čára", "RectangleTool": "Obdélník", "EllipseTool": "Elipsa", - "PaletteManagementMenu": "Spravovat barevné předvolby" + "PaletteManagementMenu": "Spravovat barevné předvolby", + "BetaVersion": "Beta verze" }, "status": { "FileTooLarge": "Soubor je příliš velký" diff --git a/packages/presentation/lang/de.json b/packages/presentation/lang/de.json index d9ea8df19fc..742489df916 100644 --- a/packages/presentation/lang/de.json +++ b/packages/presentation/lang/de.json @@ -56,7 +56,8 @@ "LineTool": "Linie", "RectangleTool": "Rechteck", "EllipseTool": "Ellipse", - "PaletteManagementMenu": "Farbpresets verwalten" + "PaletteManagementMenu": "Farbpresets verwalten", + "BetaVersion": "Beta-Version" }, "status": { "FileTooLarge": "Datei zu groß" diff --git a/packages/presentation/lang/en.json b/packages/presentation/lang/en.json index 5bc374d2afe..2bbb1fb4317 100644 --- a/packages/presentation/lang/en.json +++ b/packages/presentation/lang/en.json @@ -56,7 +56,8 @@ "LineTool": "Line", "RectangleTool": "Rectangle", "EllipseTool": "Ellipse", - "PaletteManagementMenu": "Manage color presets" + "PaletteManagementMenu": "Manage color presets", + "BetaVersion": "Beta version" }, "status": { "FileTooLarge": "File too large" diff --git a/packages/presentation/lang/es.json b/packages/presentation/lang/es.json index 9381530fae9..ddf2d045524 100644 --- a/packages/presentation/lang/es.json +++ b/packages/presentation/lang/es.json @@ -56,7 +56,8 @@ "LineTool": "Línea", "RectangleTool": "Rectángulo", "EllipseTool": "Elipse", - "PaletteManagementMenu": "Gestionar preajustes de color" + "PaletteManagementMenu": "Gestionar preajustes de color", + "BetaVersion": "Versión beta" }, "status": { "FileTooLarge": "Archivo demasiado grande" diff --git a/packages/presentation/lang/fr.json b/packages/presentation/lang/fr.json index ca016bba899..c3c2188f455 100644 --- a/packages/presentation/lang/fr.json +++ b/packages/presentation/lang/fr.json @@ -56,7 +56,8 @@ "LineTool": "Ligne", "RectangleTool": "Rectangle", "EllipseTool": "Ellipse", - "PaletteManagementMenu": "Gérer les préréglages de couleur" + "PaletteManagementMenu": "Gérer les préréglages de couleur", + "BetaVersion": "Version bêta" }, "status": { "FileTooLarge": "Fichier trop volumineux" diff --git a/packages/presentation/lang/it.json b/packages/presentation/lang/it.json index 8331b58e276..6a54c2fbc4d 100644 --- a/packages/presentation/lang/it.json +++ b/packages/presentation/lang/it.json @@ -56,7 +56,8 @@ "LineTool": "Linea", "RectangleTool": "Rettangolo", "EllipseTool": "Ellisse", - "PaletteManagementMenu": "Gestisci i preset di colore" + "PaletteManagementMenu": "Gestisci i preset di colore", + "BetaVersion": "Versione beta" }, "status": { "FileTooLarge": "File troppo grande" diff --git a/packages/presentation/lang/ja.json b/packages/presentation/lang/ja.json index 55de4e0de76..fbedfaadb14 100644 --- a/packages/presentation/lang/ja.json +++ b/packages/presentation/lang/ja.json @@ -56,7 +56,8 @@ "LineTool": "直線", "RectangleTool": "長方形", "EllipseTool": "楕円", - "PaletteManagementMenu": "カラープリセットを管理" + "PaletteManagementMenu": "カラープリセットを管理", + "BetaVersion": "ベータ版" }, "status": { "FileTooLarge": "ファイルサイズが大きすぎます" diff --git a/packages/presentation/lang/pt-br.json b/packages/presentation/lang/pt-br.json index 3769f744a95..d67a79afb61 100644 --- a/packages/presentation/lang/pt-br.json +++ b/packages/presentation/lang/pt-br.json @@ -56,7 +56,8 @@ "LineTool": "Linha", "RectangleTool": "Retângulo", "EllipseTool": "Elipse", - "PaletteManagementMenu": "Gerenciar predefinições de cor" + "PaletteManagementMenu": "Gerenciar predefinições de cor", + "BetaVersion": "Versão beta" }, "status": { "FileTooLarge": "Arquivo muito grande" diff --git a/packages/presentation/lang/pt.json b/packages/presentation/lang/pt.json index 3fbb5c8d633..38a79eece40 100644 --- a/packages/presentation/lang/pt.json +++ b/packages/presentation/lang/pt.json @@ -56,7 +56,8 @@ "LineTool": "Linha", "RectangleTool": "Retângulo", "EllipseTool": "Elipse", - "PaletteManagementMenu": "Gerenciar predefinições de cor" + "PaletteManagementMenu": "Gerenciar predefinições de cor", + "BetaVersion": "Versão beta" }, "status": { "FileTooLarge": "Ficheiro demasiado grande" diff --git a/packages/presentation/lang/ru.json b/packages/presentation/lang/ru.json index 5bda323ec9d..eb88f8bc23e 100644 --- a/packages/presentation/lang/ru.json +++ b/packages/presentation/lang/ru.json @@ -56,7 +56,8 @@ "LineTool": "Линия", "RectangleTool": "Прямоугольник", "EllipseTool": "Эллипс", - "PaletteManagementMenu": "Управление цветовыми пресетами" + "PaletteManagementMenu": "Управление цветовыми пресетами", + "BetaVersion": "Бета-версия" }, "status": { "FileTooLarge": "Файл слишком большой" diff --git a/packages/presentation/lang/tr.json b/packages/presentation/lang/tr.json index 4fd772345ea..8dd146aafbd 100644 --- a/packages/presentation/lang/tr.json +++ b/packages/presentation/lang/tr.json @@ -56,7 +56,8 @@ "LineTool": "Çizgi", "RectangleTool": "Dikdörtgen", "EllipseTool": "Elips", - "PaletteManagementMenu": "Renk önayarlarını yönet" + "PaletteManagementMenu": "Renk önayarlarını yönet", + "BetaVersion": "Beta sürümü" }, "status": { "FileTooLarge": "Dosya çok büyük" diff --git a/packages/presentation/lang/zh.json b/packages/presentation/lang/zh.json index 6b110070861..d31e4e02497 100644 --- a/packages/presentation/lang/zh.json +++ b/packages/presentation/lang/zh.json @@ -56,7 +56,8 @@ "LineTool": "直线", "RectangleTool": "矩形", "EllipseTool": "椭圆", - "PaletteManagementMenu": "管理颜色预设" + "PaletteManagementMenu": "管理颜色预设", + "BetaVersion": "Beta 版本" }, "status": { "FileTooLarge": "文件太大" diff --git a/packages/presentation/src/components/PluginConfigurationCard.svelte b/packages/presentation/src/components/PluginConfigurationCard.svelte new file mode 100644 index 00000000000..90064da9a0b --- /dev/null +++ b/packages/presentation/src/components/PluginConfigurationCard.svelte @@ -0,0 +1,147 @@ + + + +
+
+ + + + + + + {#if suffix !== undefined && suffix !== ''} + ({suffix}) + {/if} + {#if beta} + β + {/if} + + + { + dispatch('toggle', { enabled: e.detail === true }) + }} + /> +
+ {#if description !== undefined} +
+
+ {/if} +
+ + diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index ba646872468..a10eb625916 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -29,6 +29,7 @@ export { default as MessageViewer } from './components/MessageViewer.svelte' export { default as ObjectPopup } from './components/ObjectPopup.svelte' export { default as DocPopup } from './components/DocPopup.svelte' export { default as PDFViewer } from './components/PDFViewer.svelte' +export { default as PluginConfigurationCard } from './components/PluginConfigurationCard.svelte' export { default as SpaceCreateCard } from './components/SpaceCreateCard.svelte' export { default as SpaceMultiBoxList } from './components/SpaceMultiBoxList.svelte' export { default as SpaceSelect } from './components/SpaceSelect.svelte' diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 47c57d9d837..bf48b1d4adf 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -158,7 +158,8 @@ export default plugin(presentationId, { LineTool: '' as IntlString, RectangleTool: '' as IntlString, EllipseTool: '' as IntlString, - PaletteManagementMenu: '' as IntlString + PaletteManagementMenu: '' as IntlString, + BetaVersion: '' as IntlString }, extension: { FilePreviewExtension: '' as ComponentExtensionId, diff --git a/packages/ui/src/components/DropdownLabelsIntl.svelte b/packages/ui/src/components/DropdownLabelsIntl.svelte index ceafeacb975..47d8fecde39 100644 --- a/packages/ui/src/components/DropdownLabelsIntl.svelte +++ b/packages/ui/src/components/DropdownLabelsIntl.svelte @@ -30,7 +30,8 @@ export let label: IntlString = ui.string.DropdownDefaultLabel export let params: Record = {} export let items: DropdownIntlItem[] - export let selected: DropdownIntlItem['id'] | undefined = undefined + export let multiselect: boolean = false + export let selected: DropdownIntlItem['id'] | Array | undefined = multiselect ? [] : undefined export let disabled: boolean = false export let kind: ButtonKind = 'regular' export let size: ButtonSize = 'small' @@ -48,9 +49,11 @@ let container: HTMLElement let opened: boolean = false - $: selectedItem = items.find((x) => x.id === selected) - $: if (shouldUpdateUndefined && selected === undefined && items[0] !== undefined) { - selected = items[0].id + $: selectedItem = multiselect + ? (items ?? []).filter((p) => (selected as Array)?.includes(p.id)) + : (items ?? []).find((x) => x.id === selected) + $: if (shouldUpdateUndefined && selected === undefined && items?.[0] !== undefined) { + selected = multiselect ? [items[0].id] : items[0].id dispatch('selected', selected) } @@ -59,13 +62,24 @@ function openPopup () { if (!opened) { opened = true - showPopup(DropdownLabelsPopupIntl, { items, selected, params, withSearch }, container, (result) => { - if (result) { - selected = result - dispatch('selected', result) + showPopup( + DropdownLabelsPopupIntl, + { items, selected, params, withSearch, multiselect }, + container, + (result) => { + if (result) { + selected = result + dispatch('selected', result) + } + opened = false + }, + (result) => { + if (result != null) { + selected = result + dispatch('selected', result) + } } - opened = false - }) + ) } } @@ -98,10 +112,21 @@ on:click={openPopup} > - + + diff --git a/packages/ui/src/components/DropdownLabelsPopupIntl.svelte b/packages/ui/src/components/DropdownLabelsPopupIntl.svelte index b9ed3eea960..013de214555 100644 --- a/packages/ui/src/components/DropdownLabelsPopupIntl.svelte +++ b/packages/ui/src/components/DropdownLabelsPopupIntl.svelte @@ -23,12 +23,24 @@ import ui from '../plugin' export let items: DropdownIntlItem[] - export let selected: DropdownIntlItem['id'] | undefined = undefined + export let selected: DropdownIntlItem['id'] | Array | undefined = undefined + export let multiselect: boolean = false export let params: Record = {} export let withSearch: boolean = false export let searchPlaceholder: IntlString = ui.string.Search const dispatch = createEventDispatcher() + + function isSelected ( + selected: DropdownIntlItem['id'] | Array | undefined, + item: DropdownIntlItem + ): boolean { + if (Array.isArray(selected)) { + return selected.includes(item.id) + } else { + return item.id === selected + } + } let btns: HTMLButtonElement[] = [] const keyDown = (ev: KeyboardEvent, n?: number): void => { @@ -100,7 +112,18 @@ keyDown(ev, i) }} on:click={() => { - dispatch('close', item.id) + if (multiselect && Array.isArray(selected)) { + const index = selected.indexOf(item.id) + if (index !== -1) { + selected.splice(index, 1) + selected = selected + } else { + selected = selected === undefined ? [item.id] : [...selected, item.id] + } + dispatch('update', selected) + } else { + dispatch('close', item.id) + } }} >
@@ -110,7 +133,7 @@
- {#if item.id === selected}{/if} + {#if isSelected(selected, item)}{/if}
{/each} diff --git a/plugins/card-resources/src/__tests__/cardTableFormatter.test.ts b/plugins/card-resources/src/__tests__/cardTableFormatter.test.ts new file mode 100644 index 00000000000..0b4ea26a26a --- /dev/null +++ b/plugins/card-resources/src/__tests__/cardTableFormatter.test.ts @@ -0,0 +1,546 @@ +// +// 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 cardPlugin from '@hcengineering/card' +import core, { type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' +import { type AttributeModel } from '@hcengineering/view' +import { formatCardValue, formatMarkupForCell } from '../cardTableFormatter' + +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + translate: jest.fn(async (str: unknown) => String(str)) + } +}) + +jest.mock('@hcengineering/presentation', () => ({ + getClient: jest.fn() +})) + +// converter-resources transitively pulls in svelte; only isIntlString is used by +// cardTableFormatter and only on code paths the markup tests do not exercise. +jest.mock('@hcengineering/converter-resources', () => ({ + isIntlString: (value: unknown) => typeof value === 'string' && /^[a-z][a-z0-9-]*:[a-zA-Z][a-zA-Z0-9_]*:.+/.test(value) +})) + +function buildMarkupAttr (key: string): AttributeModel { + return { + key, + sortingKey: key, + _class: cardPlugin.class.Card, + label: `card:string:${key}` as unknown, + attribute: { + name: key, + type: { _class: core.class.TypeMarkup } + }, + collectionAttr: false, + isLookup: false + } as unknown as AttributeModel +} + +function buildHierarchy (): Hierarchy { + return { + isDerived: jest.fn((cls: Ref>, target: Ref>) => cls === target) + } as unknown as Hierarchy +} + +describe('cardTableFormatter.formatCardValue (markup)', () => { + it('converts ProseMirror JSON markup to markdown text', async () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }] + } + ] + }) + + const card = { + _class: cardPlugin.class.Card, + richtext: markup + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('Hello world') + }) + + it('preserves inline emphasis when serializing markup to markdown', async () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'see ' }, + { type: 'text', marks: [{ type: 'bold' }], text: 'this' } + ] + } + ] + }) + + const card = { + _class: cardPlugin.class.Card, + richtext: markup + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('see **this**') + }) + + it('returns empty string when markup value is empty', async () => { + const card = { + _class: cardPlugin.class.Card, + richtext: '' + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('') + }) + + it('returns empty string when markup value is undefined', async () => { + const card = { + _class: cardPlugin.class.Card + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('') + }) + + it('treats a plain (non-JSON) string markup value as a single paragraph', async () => { + const card = { + _class: cardPlugin.class.Card, + richtext: 'just text' + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('just text') + }) + + it('reads markup value from the cast mixin doc when castRequest is set', async () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'mixin value' }] + } + ] + }) + + // formatValue's resolveDisplayContext already passes the cast doc as displayDoc, + // so formatCardValue receives the mixin-shaped object directly. + const castDoc = { + _class: cardPlugin.class.Card, + richtext: markup + } as unknown as Doc + + const attr = { + key: 'card:mixin:MyTag.richtext', + sortingKey: 'card:mixin:MyTag.richtext', + _class: cardPlugin.class.Card, + label: 'card:string:Richtext' as unknown, + castRequest: 'card:mixin:MyTag', + attribute: { + name: 'richtext', + type: { _class: core.class.TypeMarkup } + }, + collectionAttr: false, + isLookup: false + } as unknown as AttributeModel + + const result = await formatCardValue( + attr, + castDoc, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('mixin value') + }) + + it('flattens markup containing a table to inline text (no HTML in cell)', async () => { + // markupToMarkdown serializes table nodes as raw HTML; if that HTML lands in + // an outer markdown table cell it breaks paste round-trip in the editor. + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h' }] }] + } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] + } + ] + } + ] + } + ] + }) + + const card = { + _class: cardPlugin.class.Card, + richtext: markup + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).not.toMatch(/<[^>]+>/) + expect(result).not.toContain('\n') + expect(result).toBe('h 1') + }) + + it('flattens multi-paragraph markup to a single line', async () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'first' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'second' }] } + ] + }) + + const card = { + _class: cardPlugin.class.Card, + richtext: markup + } as unknown as Doc + + const result = await formatCardValue( + buildMarkupAttr('richtext'), + card, + buildHierarchy(), + cardPlugin.class.Card as Ref>, + 'en' + ) + + expect(result).toBe('first second') + }) + + it('flattens markup for a custom-attribute column (attr.key === "" and label starts with "custom")', async () => { + // Custom markup attribute: column has empty key and a "customXXX" label; + // the value sits on the card under that same custom key. Without this + // branch the customFormatter returns undefined and the fallback emits + // raw HTML for nested tables. + const customKey = 'custom6a05575137207bd342d60f7c' + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h' }] }] + } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] + } + ] + } + ] + } + ] + }) + + const card = { + _class: cardPlugin.class.Card, + [customKey]: markup + } as unknown as Doc + + const attr = { + key: '', + sortingKey: '', + _class: cardPlugin.class.Card, + label: customKey, + collectionAttr: false, + isLookup: false + } as unknown as AttributeModel + + const markupAttribute = { + name: customKey, + type: { _class: core.class.TypeMarkup } + } + const hierarchy = { + isDerived: jest.fn((cls: Ref>, target: Ref>) => cls === target), + findAttribute: jest.fn((_cls: Ref>, key: string) => (key === customKey ? markupAttribute : undefined)), + getAllAttributes: jest.fn(() => new Map()) + } as unknown as Hierarchy + + const result = await formatCardValue(attr, card, hierarchy, cardPlugin.class.Card as Ref>, 'en') + + expect(result).not.toMatch(/<[^>]+>/) + expect(result).not.toContain('\n') + expect(result).toBe('h 1') + }) + + it('falls through to undefined for a custom-attribute column that is NOT markup', async () => { + const customKey = 'customStringField' + const card = { + _class: cardPlugin.class.Card, + [customKey]: 'just a string' + } as unknown as Doc + + const attr = { + key: '', + sortingKey: '', + _class: cardPlugin.class.Card, + label: customKey, + collectionAttr: false, + isLookup: false + } as unknown as AttributeModel + + const hierarchy = { + isDerived: jest.fn((cls: Ref>, target: Ref>) => cls === target), + findAttribute: jest.fn(() => ({ name: customKey, type: { _class: core.class.TypeString } })), + getAllAttributes: jest.fn(() => new Map()) + } as unknown as Hierarchy + + const result = await formatCardValue(attr, card, hierarchy, cardPlugin.class.Card as Ref>, 'en') + expect(result).toBeUndefined() + }) + + it('returns undefined for non-markup attributes (falls through to default formatter)', async () => { + const card = { + _class: cardPlugin.class.Card, + title: 'Some title' + } as unknown as Doc + + const attr = { + key: 'title', + sortingKey: 'title', + _class: cardPlugin.class.Card, + label: 'card:string:Title', + attribute: { + name: 'title', + type: { _class: core.class.TypeString } + }, + collectionAttr: false, + isLookup: false + } as unknown as AttributeModel + + const result = await formatCardValue(attr, card, buildHierarchy(), cardPlugin.class.Card as Ref>, 'en') + + expect(result).toBeUndefined() + }) +}) + +describe('cardTableFormatter.formatMarkupForCell', () => { + it('produces inline-only output for markup with a nested table (no HTML, no newlines)', () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'header' }] }] + } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }] + } + ] + } + ] + } + ] + }) + + const result = formatMarkupForCell(markup) + + // Critical invariants: no HTML tags survive, no embedded newlines, content preserved. + expect(result).not.toMatch(/<[^>]+>/) + expect(result).not.toContain('\n') + expect(result).toBe('header body') + }) + + it('preserves inline emphasis in flat markup', () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'see ' }, + { type: 'text', marks: [{ type: 'bold' }], text: 'this' } + ] + } + ] + }) + + expect(formatMarkupForCell(markup)).toBe('see **this**') + }) + + it('preserves a regular markdown link', () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'docs', + marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] + } + ] + } + ] + }) + + expect(formatMarkupForCell(markup)).toBe('[docs](https://example.com)') + }) + + it('preserves an autolink (URL whose link text equals the href)', () => { + // The serializer emits autolinks as ; the previous + // /<[^>]*>/g strip was deleting them entirely. + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'https://example.com', + marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] + } + ] + } + ] + }) + + expect(formatMarkupForCell(markup)).toContain('https://example.com') + }) + + it('preserves a link whose URL contains spaces (angle-bracket wrapped)', () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'doc', + marks: [{ type: 'link', attrs: { href: 'https://example.com/path with space' } }] + } + ] + } + ] + }) + + expect(formatMarkupForCell(markup)).toContain('https://example.com/path with space') + }) + + it('flattens lists to a single line', () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }] + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }] + } + ] + } + ] + }) + + const result = formatMarkupForCell(markup) + expect(result).not.toContain('\n') + expect(result).toContain('one') + expect(result).toContain('two') + }) +}) diff --git a/plugins/card-resources/src/__tests__/markupCellRoundTrip.test.ts b/plugins/card-resources/src/__tests__/markupCellRoundTrip.test.ts new file mode 100644 index 00000000000..26cf9336c92 --- /dev/null +++ b/plugins/card-resources/src/__tests__/markupCellRoundTrip.test.ts @@ -0,0 +1,198 @@ +// +// 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 +// + +import { markdownToMarkup } from '@hcengineering/text-markdown' +import { formatMarkupForCell } from '../cardTableFormatter' + +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + translate: jest.fn(async (str: unknown) => String(str)) + } +}) + +jest.mock('@hcengineering/presentation', () => ({ + getClient: jest.fn() +})) + +// converter-resources transitively pulls in svelte; only isIntlString is used +// by cardTableFormatter and is not exercised by these round-trip tests. +jest.mock('@hcengineering/converter-resources', () => ({ + isIntlString: (value: unknown) => typeof value === 'string' && /^[a-z][a-z0-9-]*:[a-zA-Z][a-zA-Z0-9_]*:.+/.test(value) +})) + +// Mirror of escapeMarkdownTableCellContent / escapeMarkdownLinkText from +// @hcengineering/converter-resources so we don't pull svelte into Jest. +function escapeMarkdownTableCellContent (value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' ') +} + +// Walk a markup tree and assert no node violates ProseMirror's table schema: +// `tableHeader` / `tableCell` may only contain block content (paragraph, etc.), +// and a `paragraph` may not contain block-level nodes such as tableRow. +function assertNoInvalidTableNesting (node: any): void { + if (node == null || typeof node !== 'object') return + if (node.type === 'paragraph') { + for (const child of node.content ?? []) { + const ct = child?.type + if (ct === 'tableRow' || ct === 'tableHeader' || ct === 'tableCell' || ct === 'table') { + throw new Error(`paragraph contains block-level ${String(ct)}`) + } + } + } + for (const child of node.content ?? []) { + assertNoInvalidTableNesting(child) + } +} + +describe('markup-in-cell round-trip', () => { + it('a markup table flattens to inline text that re-parses without invalid nested-table structures', () => { + const innerMarkup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h' }] }] + } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] + } + ] + } + ] + } + ] + }) + + const cellValue = formatMarkupForCell(innerMarkup) + const escaped = escapeMarkdownTableCellContent(cellValue) + + const outerTable = + '| Title | RichText | Date |\n' + '| --- | --- | --- |\n' + `| Card 1 | ${escaped} | 2026-05-14 |\n` + + const parsed = markdownToMarkup(outerTable) + expect(() => { + assertNoInvalidTableNesting(parsed) + }).not.toThrow() + }) + + it('reparses the exact failing user table (custom markup column with nested table) without violating the schema', () => { + // This mirrors the actual table the user reported. The "TM 2" cell holds + // a card markup attribute whose value is a markup-table. After the fix + // that cell should be flat text, so re-parsing the outer table should + // not produce a paragraph that wraps tableRow/tableHeader/tableCell. + const inner = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'test' }] }] }, + { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Type' }] }] }, + { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Tags' }] }] } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 't2', + marks: [ + { + type: 'link', + attrs: { href: 'http://huly.local:8080/workbench/t4/card/6a05579937207bd342d60ff8' } + } + ] + } + ] + } + ] + }, + { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'test' }] }] }, + { type: 'tableCell', content: [{ type: 'paragraph' }] } + ] + } + ] + } + ] + }) + + const cellValue = formatMarkupForCell(inner) + + // Critical invariants on the cell value: + expect(cellValue).not.toMatch(/<[^>]+>/) // no HTML tags + expect(cellValue).not.toContain('\n') // no embedded newlines + expect(cellValue).toContain('test') // header text preserved + // The inner link should still carry its URL. + expect(cellValue).toContain('http://huly.local:8080/workbench/t4/card/6a05579937207bd342d60ff8') + + const escaped = escapeMarkdownTableCellContent(cellValue) + const outerTable = + '| test | Type | Tags | TM 2 |\n' + + '| --- | --- | --- | --- |\n' + + `| [new](http://huly.local:8087/workbench/t4/card/6a05606432d68883381bbeb6) | test | | ${escaped} |\n` + + const parsed = markdownToMarkup(outerTable) + expect(() => { + assertNoInvalidTableNesting(parsed) + }).not.toThrow() + }) + + it('inline emphasis survives the round-trip', () => { + const markup = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'see ' }, + { type: 'text', marks: [{ type: 'bold' }], text: 'this' } + ] + } + ] + }) + + const cellValue = formatMarkupForCell(markup) + const outerTable = '| RichText |\n' + '| --- |\n' + `| ${escapeMarkdownTableCellContent(cellValue)} |\n` + + const parsed = markdownToMarkup(outerTable) + expect(() => { + assertNoInvalidTableNesting(parsed) + }).not.toThrow() + // The serialized cell should still carry the bold markdown. + expect(cellValue).toBe('see **this**') + }) +}) diff --git a/plugins/card-resources/src/cardTableFormatter.ts b/plugins/card-resources/src/cardTableFormatter.ts index 19ae871487e..86cf05f09e1 100644 --- a/plugins/card-resources/src/cardTableFormatter.ts +++ b/plugins/card-resources/src/cardTableFormatter.ts @@ -13,15 +13,45 @@ // limitations under the License. // -import { type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' +import core, { type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' import { translate, type IntlString } from '@hcengineering/platform' import cardPlugin, { type Card, type CardSpace } from '@hcengineering/card' import { type AttributeModel } from '@hcengineering/view' import { getClient } from '@hcengineering/presentation' import { isIntlString } from '@hcengineering/converter-resources' +import { markupToJSON } from '@hcengineering/text' +import { markupToMarkdown } from '@hcengineering/text-markdown' import { getCardIds, getCardVersion } from './cardUtils' import { formatCardTagsForMarkdown, isTagsColumn } from './tagFormatter' +const HTML_TAG_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s[^>]*)?\/?>/g +const HTML_COMMENT_RE = //g +const HTML_ANCHOR_RE = /]*?\bhref="([^"]*)"[^>]*?>([\s\S]*?)<\/a>/gi + +function stripInlineHtml (s: string): string { + return s.replace(HTML_COMMENT_RE, ' ').replace(HTML_TAG_RE, ' ').replace(/\s+/g, ' ').trim() +} + +/** + * Convert a stored markup value (serialized ProseMirror JSON) into a single-line + * markdown string safe to embed inside an outer markdown table cell. + */ +export function formatMarkupForCell (markup: string): string { + const markdown = markupToMarkdown(markupToJSON(markup)) + return markdown + .replace(HTML_ANCHOR_RE, (_match, href: string, inner: string) => { + const text = stripInlineHtml(inner) + if (text === '' || text === href) { + return href + } + return `[${text}](${href})` + }) + .replace(HTML_COMMENT_RE, ' ') + .replace(HTML_TAG_RE, ' ') + .replace(/\s+/g, ' ') + .trim() +} + /** * Cache for MasterTag ID -> label mappings to reduce database calls */ @@ -117,9 +147,45 @@ export async function formatCardValue ( return await formatCardTagsForMarkdown(card as Card, hierarchy, language) } + // Handle markup attribute - serialize stored markup JSON to inline-safe markdown + // so that copy-as-markdown produces readable text instead of raw ProseMirror JSON. + if (attr.attribute?.type?._class === core.class.TypeMarkup) { + const fieldName = attr.attribute.name + const markupValue: unknown = cardDoc[fieldName] + if (markupValue === undefined || markupValue === null || markupValue === '') { + return '' + } + if (typeof markupValue !== 'string') { + return '' + } + try { + return formatMarkupForCell(markupValue) + } catch (error) { + console.warn('Failed to convert markup to markdown for attribute:', fieldName, error) + return '' + } + } + if (attr.key === '') { const labelStr = typeof attr.label === 'string' ? attr.label : '' if (labelStr.startsWith('custom')) { + const customAttr = + hierarchy.findAttribute(card._class, labelStr) ?? hierarchy.getAllAttributes(card._class).get(labelStr) + if (customAttr?.type?._class === core.class.TypeMarkup) { + const markupValue: unknown = cardDoc[labelStr] + if (markupValue === undefined || markupValue === null || markupValue === '') { + return '' + } + if (typeof markupValue !== 'string') { + return '' + } + try { + return formatMarkupForCell(markupValue) + } catch (error) { + console.warn('Failed to convert custom markup attribute to markdown:', labelStr, error) + return '' + } + } return undefined } const cardObj = card as unknown as Card diff --git a/plugins/card-resources/src/components/CardAttributeEditor.svelte b/plugins/card-resources/src/components/CardAttributeEditor.svelte index b5c7766d567..47158c15a79 100644 --- a/plugins/card-resources/src/components/CardAttributeEditor.svelte +++ b/plugins/card-resources/src/components/CardAttributeEditor.svelte @@ -71,26 +71,14 @@
{/if}
- value.readonlySections?.includes(p))} - {value} - {ignoreKeys} - fourRows={columns === 2} - /> +
{#if mixins.length > 0}
{#each mixins as tag, i (tag._id)}
- +
{/each}
diff --git a/plugins/card-resources/src/components/CardVersionSelector.svelte b/plugins/card-resources/src/components/CardVersionSelector.svelte index 9c579f9ecae..1e7d165c5c7 100644 --- a/plugins/card-resources/src/components/CardVersionSelector.svelte +++ b/plugins/card-resources/src/components/CardVersionSelector.svelte @@ -20,7 +20,7 @@ import { createQuery, getClient } from '@hcengineering/presentation' import { Button, DropdownLabels, DropdownTextItem, getCurrentLocation, navigate, showPopup } from '@hcengineering/ui' import card from '../plugin' - import NewVersionPopup from './NewVersionPopup.svelte' + import { createNewVersion } from '../utils' export let value: Card @@ -70,10 +70,12 @@ } } - function newVersion (): void { - showPopup(NewVersionPopup, { - value - }) + async function newVersion (): Promise { + const _id = await createNewVersion(value) + const loc = getCurrentLocation() + loc.path[2] = cardId + loc.path[3] = _id + navigate(loc) } diff --git a/plugins/card-resources/src/components/DuplicateCard.svelte b/plugins/card-resources/src/components/DuplicateCard.svelte new file mode 100644 index 00000000000..205e08cac5a --- /dev/null +++ b/plugins/card-resources/src/components/DuplicateCard.svelte @@ -0,0 +1,203 @@ + + + + +
+ {#if allProperties.length > 0} +
+
+ {/if} + {#if mixins.length > 0} +
+
+ {/if} + {#if relationsA.length > 0 || relationsB.length > 0} +
+
+
+
+ {#each relationsB as assoc} + {@const id = `${assoc._id}_b`} + {assoc.nameB} + { + if (e.detail === true) { + excludedRelations.delete(id) + } else { + excludedRelations.add(id) + } + excludedRelations = excludedRelations + }} + /> + {/each} + {#each relationsA as assoc} + {@const id = `${assoc._id}_a`} + {assoc.nameA} + { + if (e.detail === true) { + excludedRelations.delete(id) + } else { + excludedRelations.add(id) + } + excludedRelations = excludedRelations + }} + /> + {/each} +
+ {/if} +
+ + + diff --git a/plugins/card-resources/src/components/EditCardNew.svelte b/plugins/card-resources/src/components/EditCardNew.svelte index 19b6b8477be..31b6a25d175 100644 --- a/plugins/card-resources/src/components/EditCardNew.svelte +++ b/plugins/card-resources/src/components/EditCardNew.svelte @@ -277,7 +277,7 @@ }} /> - {#if !_readonly} + {#if !readonly}
{/each} diff --git a/plugins/card-resources/src/components/MasterTagAttributes.svelte b/plugins/card-resources/src/components/MasterTagAttributes.svelte index e8826859f24..6bf62a850dd 100644 --- a/plugins/card-resources/src/components/MasterTagAttributes.svelte +++ b/plugins/card-resources/src/components/MasterTagAttributes.svelte @@ -57,6 +57,7 @@ } $: isLocked = hierarchy.getAncestors(value._class).some((p) => value.readonlySections?.includes(p)) ?? false + $: _readonly = readonly || isLocked $: canLock = canLockSection(value.space, $permissionsStore) $: canUnlock = canUnlockSection(value.space, $permissionsStore) @@ -85,6 +86,7 @@ icon={isLocked ? Lock : Unlock} kind={'link'} size={'medium'} + disabled={readonly} showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }} on:click={toggleLock} /> @@ -120,8 +122,8 @@
- - + + diff --git a/plugins/card-resources/src/components/TagAttributes.svelte b/plugins/card-resources/src/components/TagAttributes.svelte index 114a81424b9..8995394fe3c 100644 --- a/plugins/card-resources/src/components/TagAttributes.svelte +++ b/plugins/card-resources/src/components/TagAttributes.svelte @@ -64,6 +64,7 @@ $: isLocked = value.readonlySections?.includes(tag._id) ?? false $: canLock = canLockSection(value.space, $permissionsStore) $: canUnlock = canUnlockSection(value.space, $permissionsStore) + $: _readonly = readonly || isLocked async function toggleLock (ev: MouseEvent): Promise { ev.stopPropagation() @@ -90,6 +91,7 @@ + {#if showApiDocs} +
+

+ +
+ + copySnippet(baseApiUrl)} + on:keydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') copySnippet(baseApiUrl) + }}>{baseApiUrl} +
+ +

+ +
+
+
GET
+ /api/v1/ping/:workspaceId + +
+
+
GET
+ /api/v1/find-all/:workspaceId?class=... + +
+
+
POST
+ /api/v1/find-all/:workspaceId + +
+
+
POST
+ /api/v1/tx/:workspaceId + +
+
+
GET
+ /api/v1/load-model/:workspaceId + +
+
+
GET
+ /api/v1/account/:workspaceId + +
+
+ +
+ Example +
 copySnippet(curlExample)}
+          on:keydown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') copySnippet(curlExample)
+          }}>{curlExample}
+
+
+ {/if} + + + diff --git a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte new file mode 100644 index 00000000000..f59d5105c4a --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte @@ -0,0 +1,211 @@ + + + + { + dispatch('close', createdToken !== undefined) + }} +> + {#if createdToken !== undefined} +
+ +
{ + if (e.key === 'Enter' || e.key === ' ') copyToken() + }} + > + {createdToken} +
+
+ {:else} +
+ +
+
+ + +
+
+ + { + selectedScopePreset = e.detail + }} + /> +
+
+ + { + selectedExpiry = e.detail + }} + /> +
+ {#if error !== undefined} +
+ {/if} + {/if} +
+ + diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte new file mode 100644 index 00000000000..1370ddf69dd --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -0,0 +1,239 @@ + + + +
+
+ + + + +
+
+
+ {#if loading} + + {:else if loadError} +
+
+ {:else if tokens.length === 0} +
+
+ {:else} + +
+ + + + + + + + + + + + + {#each tokens as token} + {@const status = getStatus(token)} + + + + + + + + + + {/each} + +
{token.name}{token.workspaceName}{getScopeLabel(token)}{formatDate(token.createdOn)}{token.revoked ? '—' : formatDate(token.expiresOn)} + + + + {#if !token.revoked} + { + revoke(token) + }} + /> + {/if} +
+ + {/if} + + + + + + + diff --git a/plugins/setting-resources/src/components/ClassAttributesList.svelte b/plugins/setting-resources/src/components/ClassAttributesList.svelte index 2a1fbf0b6be..e65ab2cfc9a 100644 --- a/plugins/setting-resources/src/components/ClassAttributesList.svelte +++ b/plugins/setting-resources/src/components/ClassAttributesList.svelte @@ -31,6 +31,7 @@ import { Action, AnySvelteComponent, + IconCopy, IconDelete, IconEdit, Menu, @@ -42,6 +43,7 @@ import ClassAttributeRow from './ClassAttributeRow.svelte' import { makeRank } from '@hcengineering/rank' import EditAttribute from './EditAttribute.svelte' + import { TypeIdentifier } from '@hcengineering/model' export let _class: Ref> export let ofClass: Ref> | undefined = undefined @@ -97,6 +99,23 @@ showPopup(EditAttribute, { attribute, exist }, 'top', update) } + export async function overrideAttribute (source: AnyAttribute): Promise { + const newSeq = await client.createDoc(core.class.CustomSequence, core.space.Workspace, { + prefix: '', + sequence: 0, + attachedTo: core.class.CustomSequence + }) + const _id = await client.createDoc(core.class.Attribute, core.space.Model, { + ...source, + type: TypeIdentifier(newSeq), + attributeOf: _class + }) + const attribute = await client.findOne(core.class.Attribute, _id) + if (attribute !== undefined) { + showPopup(EditAttribute, { attribute, exist: true }, 'top', update) + } + } + export async function removeAttribute (attribute: AnyAttribute, exist: boolean): Promise { showPopup( MessageBox, @@ -125,6 +144,15 @@ } ] if (attribute.isCustom === true) { + if (attribute.attributeOf !== _class && attribute.type._class === core.class.TypeIdentifier) { + actions.push({ + label: settings.string.OverrideAttribute, + icon: IconCopy, + action: async () => { + await overrideAttribute(attribute) + } + }) + } actions.push({ label: presentation.string.Remove, icon: IconDelete, diff --git a/plugins/setting-resources/src/components/Configure.svelte b/plugins/setting-resources/src/components/Configure.svelte index 8e03c0e6bf6..478ffa485ac 100644 --- a/plugins/setting-resources/src/components/Configure.svelte +++ b/plugins/setting-resources/src/components/Configure.svelte @@ -20,10 +20,10 @@ pluginConfigurationStore, hasResource, isDisabled, - isAdminUser + PluginConfigurationCard } from '@hcengineering/presentation' import ratingPlugin, { getRaiting, type PersonRating } from '@hcengineering/rating' - import { Breadcrumb, Button, Header, Icon, IconInfo, Label, Scroller } from '@hcengineering/ui' + import { Breadcrumb, Header, Label, Scroller } from '@hcengineering/ui' import setting from '../plugin' const client = getClient() @@ -57,41 +57,20 @@