From 58295e41697a1867617f936ed027ce77884ba901 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Mon, 2 Mar 2026 19:18:11 +0700 Subject: [PATCH 001/128] feat: auto generate rank in middleware (#10577) Signed-off-by: Alexander Onnikov --- common/config/rush/pnpm-lock.yaml | 5 +- foundations/core/packages/core/src/classes.ts | 7 ++ foundations/core/packages/model/src/dsl.ts | 6 +- .../server/packages/middleware/package.json | 1 + .../server/packages/middleware/src/index.ts | 1 + .../server/packages/middleware/src/rank.ts | 112 ++++++++++++++++++ models/card/src/index.ts | 5 +- models/controlled-documents/src/types.ts | 4 +- models/document/src/index.ts | 2 + models/task/src/index.ts | 3 +- models/time/src/index.ts | 4 +- plugins/bitrix/src/hr.ts | 17 +-- .../src/components/CreateCard.svelte | 5 +- .../src/components/add-card/AddCard.svelte | 6 +- .../board-resources/src/utils/CardUtils.ts | 5 +- .../src/components/Childs.svelte | 6 +- .../src/components/CreateCardButton.svelte | 6 +- plugins/card-resources/src/utils.ts | 5 +- .../communication-resources/src/actions.ts | 7 +- plugins/communication-resources/src/poll.ts | 7 +- .../src/components/CreateLead.svelte | 6 +- .../src/components/CreateApplication.svelte | 9 +- .../src/components/CreateVacancy.svelte | 10 +- .../src/components/CreateIssue.svelte | 9 +- .../src/components/SubIssues.svelte | 3 +- server/server-pipeline/src/pipeline.ts | 2 + 26 files changed, 172 insertions(+), 81 deletions(-) create mode 100644 foundations/server/packages/middleware/src/rank.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b6b4d32982..75a27a72e5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -5029,6 +5029,9 @@ importers: '@hcengineering/query': specifier: workspace:^0.7.17 version: link:../../../core/packages/query + '@hcengineering/rank': + specifier: workspace:^0.7.17 + version: link:../../../core/packages/rank '@hcengineering/server-core': specifier: workspace:^0.7.18 version: link:../core @@ -61377,7 +61380,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1 + webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) node-localstorage@2.2.1: dependencies: diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index 4307dbf502..20640de419 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -394,6 +394,13 @@ export interface EnumOf extends Type { */ export interface TypeHyperlink extends Type {} +/** + * @public + */ +export interface TypeRank extends Type { + pos?: 'start' | 'end' +} + /** * @public * diff --git a/foundations/core/packages/model/src/dsl.ts b/foundations/core/packages/model/src/dsl.ts index dfe0313ae1..d8e086d15d 100644 --- a/foundations/core/packages/model/src/dsl.ts +++ b/foundations/core/packages/model/src/dsl.ts @@ -38,7 +38,7 @@ import core, { type Obj, type PersonId, type PropertyType, - type Rank, + type TypeRank as TypeRankType, type Ref, type RefTo, type Space, @@ -531,8 +531,8 @@ export function TypeCollaborativeDoc (): Type { /** * @public */ -export function TypeRank (): Type { - return { _class: core.class.TypeRank, label: core.string.Rank, icon: core.icon.TypeRank } +export function TypeRank (pos: 'start' | 'end' = 'end'): TypeRankType { + return { _class: core.class.TypeRank, label: core.string.Rank, icon: core.icon.TypeRank, pos } } export function TypePersonId (): Type { diff --git a/foundations/server/packages/middleware/package.json b/foundations/server/packages/middleware/package.json index 08f1b540ea..5ec5b8bf39 100644 --- a/foundations/server/packages/middleware/package.json +++ b/foundations/server/packages/middleware/package.json @@ -38,6 +38,7 @@ "@hcengineering/core": "workspace:^0.7.24", "@hcengineering/contact": "workspace:^0.7.0", "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/rank": "workspace:^0.7.17", "@hcengineering/server-core": "workspace:^0.7.18", "@hcengineering/query": "workspace:^0.7.17", "@hcengineering/analytics": "workspace:^0.7.17", diff --git a/foundations/server/packages/middleware/src/index.ts b/foundations/server/packages/middleware/src/index.ts index 5e673060b0..061b25ab56 100644 --- a/foundations/server/packages/middleware/src/index.ts +++ b/foundations/server/packages/middleware/src/index.ts @@ -44,3 +44,4 @@ export * from './userStatus' export * from './findSecurity' export * from './normalizeTx' export * from './versioning' +export * from './rank' diff --git a/foundations/server/packages/middleware/src/rank.ts b/foundations/server/packages/middleware/src/rank.ts new file mode 100644 index 0000000000..7ca011118e --- /dev/null +++ b/foundations/server/packages/middleware/src/rank.ts @@ -0,0 +1,112 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + type MeasureContext, + type Tx, + type SessionData, + type TxCreateDoc, + type TxApplyIf, + type Doc, + type Rank, + type TxMixin, + type TypeRank, + SortingOrder +} from '@hcengineering/core' +import { makeRank } from '@hcengineering/rank' +import { + type Middleware, + type TxMiddlewareResult, + type PipelineContext, + BaseMiddleware +} from '@hcengineering/server-core' + +/** + * Special value to indicate that rank should be auto-generated + * @public + */ +export const RANK_AUTO = '' as Rank + +export class RankMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new RankMiddleware(context, next) + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + for (const tx of txes) { + await this.processTx(ctx, tx) + } + + return await this.provideTx(ctx, txes) + } + + private async processTx (ctx: MeasureContext, tx: Tx): Promise { + if (tx._class === core.class.TxCreateDoc) { + await this.setRank(ctx, tx as TxCreateDoc) + } else if (tx._class === core.class.TxApplyIf) { + const applyTx = tx as TxApplyIf + for (const atx of applyTx.txes) { + await this.processTx(ctx, atx) + } + } + } + + private async setRank (ctx: MeasureContext, tx: TxCreateDoc | TxMixin): Promise { + const attributes = + tx._class === core.class.TxCreateDoc + ? this.context.hierarchy.getAllAttributes(tx.objectClass) + : this.context.hierarchy.getOwnAttributes((tx as TxMixin).mixin) + + for (const attr of attributes) { + if (attr[1].type._class === core.class.TypeRank) { + const rankAttr = attr[1].name + const typeRank = attr[1].type as TypeRank + + const rank = (tx.attributes as any)[rankAttr] + if (rank !== undefined && rank !== RANK_AUTO) { + continue + } + + const pos = typeRank.pos ?? 'end' + + // Query for either the first (Beginning) or last (End) document + const sortOrder = pos === 'end' ? SortingOrder.Descending : SortingOrder.Ascending + const existingDoc = await this.provideFindAll( + ctx, + tx.objectClass, + { space: tx.objectSpace }, + { + sort: { [rankAttr]: sortOrder }, + projection: { [rankAttr]: 1 }, + limit: 1 + } + ) + + const lastRank = existingDoc.length > 0 ? (existingDoc[0] as any)[rankAttr] : undefined + const newRank = pos === 'end' ? makeRank(lastRank, undefined) : makeRank(undefined, lastRank) + + ;(tx.attributes as any)[rankAttr] = newRank + } + } + } +} diff --git a/models/card/src/index.ts b/models/card/src/index.ts index 653800d84f..f301c795a7 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -63,6 +63,7 @@ import { ReadOnly, TypeCollaborativeDoc, TypeNumber, + TypeRank, TypeRef, TypeString, UX @@ -127,7 +128,9 @@ export class TCard extends TDoc implements Card { @Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files }) attachments?: number - rank!: Rank + @Prop(TypeRank(), core.string.Rank) + @Hidden() + rank!: Rank @Prop(Collection(time.class.ToDo), getEmbeddedLabel('Action Items')) todos?: CollectionSize diff --git a/models/controlled-documents/src/types.ts b/models/controlled-documents/src/types.ts index 1974b9a6ee..c6d4c0b32c 100644 --- a/models/controlled-documents/src/types.ts +++ b/models/controlled-documents/src/types.ts @@ -75,7 +75,8 @@ import { UX, TypeCollaborativeDoc, TypeMarkup, - ReadOnly + ReadOnly, + TypeRank } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' import chunter, { TChatMessage } from '@hcengineering/model-chunter' @@ -181,6 +182,7 @@ export class TProjectMeta extends TDoc implements ProjectMeta { @Prop(Collection(documents.class.ProjectDocument), documents.string.Documents) documents!: CollectionSize + @Prop(TypeRank(), core.string.Rank) @Index(IndexKind.Indexed) @Hidden() rank!: Rank diff --git a/models/document/src/index.ts b/models/document/src/index.ts index 80ced86e90..75d046bb4f 100644 --- a/models/document/src/index.ts +++ b/models/document/src/index.ts @@ -38,6 +38,7 @@ import { TypeAccountUuid, TypeCollaborativeDoc, TypeNumber, + TypeRank, TypeRef, TypeString, UX @@ -116,6 +117,7 @@ export class TDocument extends TDoc implements Document, Todoable { @Prop(Collection(time.class.ToDo), getEmbeddedLabel('Action Items')) todos?: CollectionSize + @Prop(TypeRank(), core.string.Rank) @Index(IndexKind.Indexed) @Hidden() rank!: Rank diff --git a/models/task/src/index.ts b/models/task/src/index.ts index 288c95d57f..9259c85798 100644 --- a/models/task/src/index.ts +++ b/models/task/src/index.ts @@ -36,6 +36,7 @@ import { ReadOnly, TypeBoolean, TypeDate, + TypeRank, TypeRecord, TypeRef, TypeString, @@ -113,7 +114,7 @@ export class TTask extends TAttachedDoc implements Task { @Prop(TypeDate(), task.string.DueDate, { editor: task.component.DueDateEditor }) dueDate!: Timestamp | null - @Prop(TypeString(), task.string.Rank) + @Prop(TypeRank(), task.string.Rank) @Index(IndexKind.IndexedDsc) @Hidden() rank!: Rank diff --git a/models/time/src/index.ts b/models/time/src/index.ts index d9e5f60c49..c5e604b52e 100644 --- a/models/time/src/index.ts +++ b/models/time/src/index.ts @@ -44,7 +44,8 @@ import { TypeString, UX, type Builder, - TypeMarkup + TypeMarkup, + TypeRank } from '@hcengineering/model' import { TEvent } from '@hcengineering/model-calendar' import core, { TAttachedDoc, TClass, TDoc, TType } from '@hcengineering/model-core' @@ -131,6 +132,7 @@ export class TToDo extends TAttachedDoc implements ToDo { @Prop(Collection(tags.class.TagReference, tags.string.TagLabel), tags.string.Tags) labels?: number | undefined + @Prop(TypeRank(), core.string.Rank) @Index(IndexKind.Indexed) @Hidden() rank!: Rank diff --git a/plugins/bitrix/src/hr.ts b/plugins/bitrix/src/hr.ts index 97673adf2b..0c44213a29 100644 --- a/plugins/bitrix/src/hr.ts +++ b/plugins/bitrix/src/hr.ts @@ -1,17 +1,7 @@ import { Organization } from '@hcengineering/contact' -import core, { - PersonId, - Client, - Data, - Doc, - Ref, - SortingOrder, - Status, - TxOperations, - generateId -} from '@hcengineering/core' +import core, { PersonId, Client, Data, Doc, Ref, Status, TxOperations, generateId } from '@hcengineering/core' import recruit, { Applicant, Vacancy } from '@hcengineering/recruit' -import task, { ProjectType, makeRank } from '@hcengineering/task' +import task, { ProjectType } from '@hcengineering/task' export async function createVacancy ( rawClient: Client, @@ -71,13 +61,12 @@ export async function createApplication ( throw new Error('sequence object not found') } - const lastOne = await client.findOne(recruit.class.Applicant, {}, { sort: { rank: SortingOrder.Descending } }) const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true) await client.addCollection(recruit.class.Applicant, _space, doc._id, recruit.mixin.Candidate, 'applications', { ...data, status: selectedState._id, number: (incResult as any).object.sequence, - rank: makeRank(lastOne?.rank, undefined) + rank: '' }) } diff --git a/plugins/board-resources/src/components/CreateCard.svelte b/plugins/board-resources/src/components/CreateCard.svelte index 610e1802c3..6b3e21545a 100644 --- a/plugins/board-resources/src/components/CreateCard.svelte +++ b/plugins/board-resources/src/components/CreateCard.svelte @@ -18,7 +18,7 @@ import core, { AttachedData, Ref, SortingOrder, Space, generateId } from '@hcengineering/core' import { OK, Status } from '@hcengineering/platform' import { Card, SpaceSelector, createQuery, getClient } from '@hcengineering/presentation' - import task, { TaskType, makeRank } from '@hcengineering/task' + import { TaskType } from '@hcengineering/task' import { EditBox, Grid, Status as StatusControl } from '@hcengineering/ui' import { createEventDispatcher } from 'svelte' import board from '../plugin' @@ -62,7 +62,6 @@ throw new Error('sequence object not found') } - const lastOne = await client.findOne(board.class.Card, {}, { sort: { rank: SortingOrder.Descending } }) const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true) const number = (incResult as any).object.sequence @@ -73,7 +72,7 @@ title, kind, identifier: `CARD-${number}`, - rank: makeRank(lastOne?.rank, undefined), + rank: '', assignee: null, description: '', members: [], diff --git a/plugins/board-resources/src/components/add-card/AddCard.svelte b/plugins/board-resources/src/components/add-card/AddCard.svelte index 6e41e9011b..2a8fc2a9b4 100644 --- a/plugins/board-resources/src/components/add-card/AddCard.svelte +++ b/plugins/board-resources/src/components/add-card/AddCard.svelte @@ -15,8 +15,7 @@ + +{#if query !== undefined && modeSelectorProps !== undefined} + +{/if} diff --git a/plugins/card-resources/src/index.ts b/plugins/card-resources/src/index.ts index d2c77a033f..b2aa2548f2 100644 --- a/plugins/card-resources/src/index.ts +++ b/plugins/card-resources/src/index.ts @@ -64,6 +64,7 @@ import CreateRolePopup from './components/settings/CreateRolePopup.svelte' import CardWidget from './components/CardWidget.svelte' import CreateSpace from './components/navigator/CreateSpace.svelte' import CardHeaderButton from './components/navigator/CardHeaderButton.svelte' +import MyCards from './components/navigator/MyCards.svelte' // Card Sections import AttachmentsCardSection from './components/sections/AttachmentsSection.svelte' @@ -134,7 +135,8 @@ export default async (): Promise => ({ CardFeedView, CreateSpace, CardHeaderButton, - CreateRolePopup + CreateRolePopup, + MyCards }, sectionComponent: { AttachmentsSection: AttachmentsCardSection, diff --git a/plugins/card-resources/src/plugin.ts b/plugins/card-resources/src/plugin.ts index 215ee5ce4e..64d0bfe8b4 100644 --- a/plugins/card-resources/src/plugin.ts +++ b/plugins/card-resources/src/plugin.ts @@ -55,7 +55,8 @@ export default mergeIds(cardId, card, { CardWidgetTab: '' as AnyComponent, CreateCard: '' as AnyComponent, CardHeaderButton: '' as AnyComponent, - CreateRolePopup: '' as AnyComponent + CreateRolePopup: '' as AnyComponent, + MyCards: '' as AnyComponent }, function: { CardFactory: '' as Resource<(props?: Record) => Promise | undefined>>, @@ -161,6 +162,8 @@ export default mergeIds(cardId, card, { ForbidAddTagPermission: '' as IntlString, ForbidRemoveTag: '' as IntlString, CardUpdated: '' as IntlString, - CardCreated: '' as IntlString + CardCreated: '' as IntlString, + MyCards: '' as IntlString, + GotoMyCards: '' as IntlString } }) From cad04875f780a194a3cf2abc51e8c16d9bf70bc7 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Tue, 10 Mar 2026 10:25:46 +0700 Subject: [PATCH 021/128] Add notifications for reviewed documents (#10601) * Send notification when document reviewed Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- models/controlled-documents/src/index.ts | 28 ++++++++++++++++++- .../server-controlled-documents/src/index.ts | 9 ++++++ plugins/controlled-documents/src/plugin.ts | 3 +- .../src/index.ts | 18 +++++++++++- .../controlled-documents/src/index.ts | 3 +- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index 5f333087a7..fb4860d61a 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -1218,6 +1218,28 @@ export function defineNotifications (builder: Builder): void { documents.notification.StateNotification ) + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + hidden: false, + generated: false, + allowedForAuthor: true, + label: documents.string.Review, + group: documents.notification.DocumentsNotificationGroup, + field: 'controlledState', + txClasses: [core.class.TxUpdateDoc], + objectClass: documents.class.ControlledDocument, + defaultEnabled: true, + templates: { + textTemplate: '{sender} marked {doc} as reviewed', + htmlTemplate: '

{sender} marked {doc} as reviewed

', + subjectTemplate: '{doc} reviewed' + } + }, + documents.notification.ReviewNotification + ) + builder.createDoc( notification.class.NotificationType, core.space.Model, @@ -1243,7 +1265,11 @@ export function defineNotifications (builder: Builder): void { builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { provider: notification.providers.InboxNotificationProvider, ignoredTypes: [], - enabledTypes: [documents.notification.StateNotification, documents.notification.ContentNotification] + enabledTypes: [ + documents.notification.StateNotification, + documents.notification.ContentNotification, + documents.notification.ReviewNotification + ] }) generateClassNotificationTypes( diff --git a/models/server-controlled-documents/src/index.ts b/models/server-controlled-documents/src/index.ts index a97537945b..1bbeeac478 100644 --- a/models/server-controlled-documents/src/index.ts +++ b/models/server-controlled-documents/src/index.ts @@ -72,6 +72,15 @@ export function createModel (builder: Builder): void { presenter: serverDocuments.function.ControlledDocumentHTMLPresenter }) + builder.mixin( + documents.notification.ReviewNotification, + notification.class.NotificationType, + serverNotification.mixin.TypeMatch, + { + func: serverDocuments.function.DocumentReviewedTypeMatch + } + ) + builder.mixin( documents.notification.CoAuthorsNotification, notification.class.NotificationType, diff --git a/plugins/controlled-documents/src/plugin.ts b/plugins/controlled-documents/src/plugin.ts index 0460f81531..d3460f1875 100644 --- a/plugins/controlled-documents/src/plugin.ts +++ b/plugins/controlled-documents/src/plugin.ts @@ -336,7 +336,8 @@ export const documentsPlugin = plugin(documentsId, { ProductChangeControl: '' as Ref }, notification: { - CoAuthorsNotification: '' as Ref + CoAuthorsNotification: '' as Ref, + ReviewNotification: '' as Ref }, viewlet: { DocumentSpaceTable: '' as Ref diff --git a/server-plugins/controlled-documents-resources/src/index.ts b/server-plugins/controlled-documents-resources/src/index.ts index b3815e706d..d7695a72b7 100644 --- a/server-plugins/controlled-documents-resources/src/index.ts +++ b/server-plugins/controlled-documents-resources/src/index.ts @@ -448,6 +448,21 @@ async function CoAuthorsTypeMatch ( return false } +async function DocumentReviewedTypeMatch ( + originTx: TxCUD, + _doc: Doc, + _person: Ref, + _socialIds: PersonId[], + _type: NotificationType, + _control: TriggerControl +): Promise { + if (originTx._class !== core.class.TxUpdateDoc) return false + + const tx = originTx as TxUpdateDoc + + return tx.operations.controlledState === ControlledDocumentState.Reviewed +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { @@ -460,6 +475,7 @@ export default async () => ({ function: { ControlledDocumentTextPresenter, ControlledDocumentHTMLPresenter, - CoAuthorsTypeMatch + CoAuthorsTypeMatch, + DocumentReviewedTypeMatch } }) diff --git a/server-plugins/controlled-documents/src/index.ts b/server-plugins/controlled-documents/src/index.ts index 9dd154976f..407b4a89be 100644 --- a/server-plugins/controlled-documents/src/index.ts +++ b/server-plugins/controlled-documents/src/index.ts @@ -27,6 +27,7 @@ export default plugin(serverDocumentsId, { function: { ControlledDocumentTextPresenter: '' as Resource, ControlledDocumentHTMLPresenter: '' as Resource, - CoAuthorsTypeMatch: '' as TypeMatchFunc + CoAuthorsTypeMatch: '' as TypeMatchFunc, + DocumentReviewedTypeMatch: '' as TypeMatchFunc } }) From 9ac0914175eb3b07e648e666758ac7a0606853f8 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Tue, 10 Mar 2026 10:35:28 +0700 Subject: [PATCH 022/128] Handle checkout errors (#10605) Signed-off-by: Artem Savchenko --- .../pod-payment/src/providers/stripe/provider.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/payment/pod-payment/src/providers/stripe/provider.ts b/services/payment/pod-payment/src/providers/stripe/provider.ts index d04f52e735..5ac46328ac 100644 --- a/services/payment/pod-payment/src/providers/stripe/provider.ts +++ b/services/payment/pod-payment/src/providers/stripe/provider.ts @@ -143,6 +143,10 @@ export class StripeProvider implements PaymentProvider { // If checkout is not complete, no subscription yet if (checkout.status !== 'complete') { + ctx.info('Cannot get subscription by checkout: checkout is not complete', { + checkoutId, + status: checkout.status + }) return null } @@ -154,6 +158,7 @@ export class StripeProvider implements PaymentProvider { const subscriptionData = transformStripeSubscriptionToData(subscription) if (subscriptionData !== null) { + ctx.info('Found subscription by checkout: subscription ID', { checkoutId, subscriptionId }) return subscriptionData } } @@ -175,9 +180,15 @@ export class StripeProvider implements PaymentProvider { if (subscriptionData !== null) { return subscriptionData + } else { + ctx.error('Cannot get subscription by checkout: subscription is in irrelevant state', { + checkoutId, + subscriptionId: activeSubscriptions[0].id + }) } } + ctx.error('Cannot get subscription by checkout: no subscriptions found', { checkoutId }) return null } catch (err) { ctx.error('Failed to get subscription by checkout', { checkoutId, err }) From badeb2ebd9e3f5ded64b9e07f25e12e8dbce5a68 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Tue, 10 Mar 2026 00:02:58 -0400 Subject: [PATCH 023/128] fix(tracker): make Project.defaultIssueStatus optional (#10598) defaultIssueStatus is not reliably populated for all projects.Make the field optional in the type definition and guard the migration to handle undefined values. Signed-off-by: Igor Loskutov --- models/tracker/src/migration.ts | 8 +++++--- models/tracker/src/types.ts | 2 +- plugins/tracker/src/index.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index ac2f219eaa..d07660e56c 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -200,10 +200,12 @@ async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLog // 1. defaultIssueStatus // 2. DocUpdateMessage:update:defaultIssueStatus for (const project of projects) { - const newDefaultIssueStatus = getNewStatus(project.defaultIssueStatus) + if (project.defaultIssueStatus != null) { + const newDefaultIssueStatus = getNewStatus(project.defaultIssueStatus) - if (project.defaultIssueStatus !== newDefaultIssueStatus) { - await client.update(DOMAIN_SPACE, { _id: project._id }, { defaultIssueStatus: newDefaultIssueStatus }) + if (project.defaultIssueStatus !== newDefaultIssueStatus) { + await client.update(DOMAIN_SPACE, { _id: project._id }, { defaultIssueStatus: newDefaultIssueStatus }) + } } const projectUpdateMessages = await client.find(DOMAIN_ACTIVITY, { diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index cb6dbb7786..09d3421f30 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -126,7 +126,7 @@ export class TProject extends TTaskProject implements Project { sequence!: number @Prop(TypeRef(tracker.class.IssueStatus), tracker.string.DefaultIssueStatus) - defaultIssueStatus!: Ref + defaultIssueStatus?: Ref @Prop(TypeRef(contact.mixin.Employee), tracker.string.DefaultAssignee) defaultAssignee!: Ref diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index f90a75abf3..bc1cba1152 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -61,7 +61,7 @@ export interface IssueStatus extends Status {} export interface Project extends TaskProject, IconProps { identifier: string // Project identifier sequence: number - defaultIssueStatus: Ref + defaultIssueStatus?: Ref defaultAssignee?: Ref defaultTimeReportDay: TimeReportDayType } From cd7b4ea7f8645042e069e98b9fb6369ecb00c75d Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Tue, 10 Mar 2026 13:55:45 +0700 Subject: [PATCH 024/128] Fix payment server config (#10607) Signed-off-by: Artem Savchenko --- services/payment/pod-payment/src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/payment/pod-payment/src/server.ts b/services/payment/pod-payment/src/server.ts index 51de273952..180c4d4bb4 100644 --- a/services/payment/pod-payment/src/server.ts +++ b/services/payment/pod-payment/src/server.ts @@ -76,6 +76,7 @@ const handleRequest = async ( export async function createServer (ctx: MeasureContext, config: Config): Promise<{ app: Express, close: () => void }> { const app = express() + app.set('trust proxy', true) app.use(cors()) const childLogger = ctx.logger.childLogger?.('requests', { enableConsole: 'true' }) From 88065119cb3598d9108821a1532e624638a87ff0 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Tue, 10 Mar 2026 13:56:12 +0700 Subject: [PATCH 025/128] Fix Stripe success URL and account id (#10608) * Fix Stripe success URL and account id Signed-off-by: Artem Savchenko * Use account uuid in update subscription Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .../pod-payment/src/providers/index.ts | 3 +- .../polar/__tests__/provider.test.ts | 118 ++++++++++++++++++ .../src/providers/polar/provider.ts | 10 +- .../stripe/__tests__/provider.test.ts | 91 +++++++++++++- .../providers/stripe/__tests__/utils.test.ts | 8 +- .../src/providers/stripe/provider.ts | 8 +- .../pod-payment/src/providers/stripe/utils.ts | 18 +-- services/payment/pod-payment/src/server.ts | 9 +- 8 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 services/payment/pod-payment/src/providers/polar/__tests__/provider.test.ts diff --git a/services/payment/pod-payment/src/providers/index.ts b/services/payment/pod-payment/src/providers/index.ts index c3b5da3783..ebd42e23fb 100644 --- a/services/payment/pod-payment/src/providers/index.ts +++ b/services/payment/pod-payment/src/providers/index.ts @@ -102,7 +102,8 @@ export interface PaymentProvider { ctx: MeasureContext, subscriptionId: string, newPlan: string, - workspaceUrl: string + workspaceUrl: string, + accountUuid: string ) => Promise /** diff --git a/services/payment/pod-payment/src/providers/polar/__tests__/provider.test.ts b/services/payment/pod-payment/src/providers/polar/__tests__/provider.test.ts new file mode 100644 index 0000000000..0cf9b797d9 --- /dev/null +++ b/services/payment/pod-payment/src/providers/polar/__tests__/provider.test.ts @@ -0,0 +1,118 @@ +import type { MeasureContext } from '@hcengineering/core' +import { AccountClient, SubscriptionType } from '@hcengineering/account-client' +import { PolarProvider } from '../provider' +import { getPlanKey } from '../../../utils' + +const mockCreateCheckout = jest.fn() +jest.mock('../client', () => ({ + PolarClient: jest.fn().mockImplementation(() => ({ + createCheckout: mockCreateCheckout + })) +})) +jest.mock('../../../utils', () => ({ + getPlanKey: jest.fn() +})) + +describe('PolarProvider', () => { + const accessToken = 'polar_token' + const webhookSecret = 'whsec_123' + const subscriptionPlans = + 'common@tier:prod_common;rare@tier:prod_rare;epic@tier:prod_epic;legendary@tier:prod_legendary' + + let accountClient: jest.Mocked + let ctx: jest.Mocked + + beforeEach(() => { + accountClient = { + getSubscriptions: jest.fn(), + upsertSubscription: jest.fn() + } as any + + jest.clearAllMocks() + + ctx = { + info: jest.fn(), + error: jest.fn(), + with: jest.fn() + } as any + + jest.clearAllMocks() + }) + + test('createSubscription removes trailing slash from frontUrl to avoid double slashes in URLs', async () => { + const frontUrlWithTrailingSlash = 'https://huly.app/' + const provider = new PolarProvider( + accessToken, + webhookSecret, + subscriptionPlans, + frontUrlWithTrailingSlash, + accountClient + ) + + const request = { + type: SubscriptionType.Tier, + plan: 'common', + customerEmail: 'user@example.test', + customerName: 'User' + } as any + + const workspaceUuid = 'workspace-uuid' as any + const workspaceUrl = 'tup' + const accountUuid = 'account-uuid' + + ;(getPlanKey as jest.Mock).mockReturnValue('common@tier') + + mockCreateCheckout.mockResolvedValue({ + id: 'checkout_123', + url: 'https://polar.sh/checkout' + } as any) + + await provider.createSubscription(ctx, request, workspaceUuid, workspaceUrl, accountUuid) + + const successUrl = mockCreateCheckout.mock.calls[0][1].successUrl + const returnUrl = mockCreateCheckout.mock.calls[0][1].returnUrl + + expect(successUrl).toBe( + 'https://huly.app/workbench/tup/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_ID}' + ) + expect(returnUrl).toBe('https://huly.app/workbench/tup/setting/setting/billing/subscriptions?payment=canceled') + expect(successUrl).not.toContain('//workbench') + expect(returnUrl).not.toContain('//workbench') + }) + + test('createSubscription strips multiple trailing slashes from frontUrl', async () => { + const frontUrlWithMultipleSlashes = 'https://huly.app///' + const provider = new PolarProvider( + accessToken, + webhookSecret, + subscriptionPlans, + frontUrlWithMultipleSlashes, + accountClient + ) + + const request = { + type: SubscriptionType.Tier, + plan: 'common', + customerEmail: 'user@example.test', + customerName: 'User' + } as any + + const workspaceUuid = 'workspace-uuid' as any + const workspaceUrl = 'ws1' + const accountUuid = 'account-uuid' + + ;(getPlanKey as jest.Mock).mockReturnValue('common@tier') + + mockCreateCheckout.mockResolvedValue({ + id: 'checkout_123', + url: 'https://polar.sh/checkout' + } as any) + + await provider.createSubscription(ctx, request, workspaceUuid, workspaceUrl, accountUuid) + + const successUrl = mockCreateCheckout.mock.calls[0][1].successUrl + expect(successUrl).toBe( + 'https://huly.app/workbench/ws1/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_ID}' + ) + }) +}) diff --git a/services/payment/pod-payment/src/providers/polar/provider.ts b/services/payment/pod-payment/src/providers/polar/provider.ts index 89a77c17bb..7a2df2b2f8 100644 --- a/services/payment/pod-payment/src/providers/polar/provider.ts +++ b/services/payment/pod-payment/src/providers/polar/provider.ts @@ -70,7 +70,7 @@ export class PolarProvider implements PaymentProvider { this.polar = new PolarClient(accessToken, useSandbox) this.webhookSecret = webhookSecret // TODO: support branding - this.frontUrl = frontUrl + this.frontUrl = frontUrl.replace(/\/+$/, '') this.subscriptionPlans = {} this.accountClient = accountClient const plans = subscriptionPlans.split(';') @@ -279,7 +279,8 @@ export class PolarProvider implements PaymentProvider { ctx: MeasureContext, subscriptionId: string, newPlan: string, - workspaceUrl: string + workspaceUrl: string, + accountUuid: string ): Promise { // Get the current subscription to check if it's free const currentSub = await this.polar.getSubscription(ctx, subscriptionId) @@ -307,13 +308,14 @@ export class PolarProvider implements PaymentProvider { successUrl, returnUrl, subscriptionId: currentSub.id, - externalCustomerId: currentSub.customerId, + externalCustomerId: accountUuid, customerEmail: currentSub.customer?.email ?? undefined, customerName: currentSub.customer?.name ?? undefined, metadata: { workspaceUuid: (currentSub.metadata?.workspaceUuid as string) ?? '', subscriptionType: SubscriptionType.Tier, - subscriptionPlan: newPlan + subscriptionPlan: newPlan, + accountUuid } }) diff --git a/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts b/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts index b6909dd145..f64cbca45d 100644 --- a/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts +++ b/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts @@ -53,6 +53,85 @@ describe('StripeProvider', () => { jest.clearAllMocks() }) + test('createSubscription removes trailing slash from frontUrl to avoid double slashes in URLs', async () => { + const frontUrlWithTrailingSlash = 'https://huly.app/' + const provider = new StripeProvider( + apiKey, + webhookSecret, + subscriptionPlans, + frontUrlWithTrailingSlash, + accountClient + ) + + const request = { + type: SubscriptionType.Tier, + plan: 'common', + customerEmail: 'user@example.test', + customerName: 'User' + } as any + + const workspaceUuid = 'workspace-uuid' as any + const workspaceUrl = 'tup' + const accountUuid = 'account-uuid' + + ;(getPlanKey as jest.Mock).mockReturnValue('common@tier') + + // eslint-disable-next-line @typescript-eslint/unbound-method + stripeClient.createCheckout.mockResolvedValue({ + checkoutId: 'cs_test_123', + url: 'https://stripe.test/checkout' + }) + + await provider.createSubscription(ctx, request, workspaceUuid, workspaceUrl, accountUuid) + + const successUrl = (stripeClient.createCheckout as jest.Mock).mock.calls[0][1].successUrl + const cancelUrl = (stripeClient.createCheckout as jest.Mock).mock.calls[0][1].cancelUrl + + expect(successUrl).toBe( + 'https://huly.app/workbench/tup/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_SESSION_ID}' + ) + expect(cancelUrl).toBe('https://huly.app/workbench/tup/setting/setting/billing/subscriptions?payment=canceled') + expect(successUrl).not.toContain('//workbench') + expect(cancelUrl).not.toContain('//workbench') + }) + + test('createSubscription strips multiple trailing slashes from frontUrl', async () => { + const frontUrlWithMultipleSlashes = 'https://huly.app///' + const provider = new StripeProvider( + apiKey, + webhookSecret, + subscriptionPlans, + frontUrlWithMultipleSlashes, + accountClient + ) + + const request = { + type: SubscriptionType.Tier, + plan: 'common', + customerEmail: 'user@example.test', + customerName: 'User' + } as any + + const workspaceUuid = 'workspace-uuid' as any + const workspaceUrl = 'ws1' + const accountUuid = 'account-uuid' + + ;(getPlanKey as jest.Mock).mockReturnValue('common@tier') + + // eslint-disable-next-line @typescript-eslint/unbound-method + stripeClient.createCheckout.mockResolvedValue({ + checkoutId: 'cs_test_123', + url: 'https://stripe.test/checkout' + }) + + await provider.createSubscription(ctx, request, workspaceUuid, workspaceUrl, accountUuid) + + const successUrl = (stripeClient.createCheckout as jest.Mock).mock.calls[0][1].successUrl + expect(successUrl).toBe( + 'https://huly.app/workbench/ws1/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_SESSION_ID}' + ) + }) + test('createSubscription creates checkout with correct parameters', async () => { const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) @@ -149,15 +228,17 @@ describe('StripeProvider', () => { }) }) - test('updateSubscriptionPlan creates checkout for free subscription', async () => { + test('updateSubscriptionPlan creates checkout for free subscription with accountUuid in metadata', async () => { const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) const subscriptionId = 'sub_free' const newPlan = 'epic' const workspaceUrl = 'workspace-url' + const accountUuid = 'acc-upgrade-123' const currentSub: Stripe.Subscription = { id: subscriptionId, + metadata: { workspaceUuid: 'ws-1' }, items: { data: [ { @@ -178,7 +259,7 @@ describe('StripeProvider', () => { url: 'https://stripe.test/checkout/new' }) - const result = await provider.updateSubscriptionPlan(ctx, subscriptionId, newPlan, workspaceUrl) + const result = await provider.updateSubscriptionPlan(ctx, subscriptionId, newPlan, workspaceUrl, accountUuid) expect(result).toEqual({ checkoutId: 'cs_test_new', @@ -190,7 +271,11 @@ describe('StripeProvider', () => { ctx, expect.objectContaining({ priceId: 'price_epic', - subscriptionId + subscriptionId, + metadata: expect.objectContaining({ + accountUuid, + subscriptionPlan: newPlan + }) }) ) }) diff --git a/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts b/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts index 78d4d6e81a..6158b19b9b 100644 --- a/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts +++ b/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts @@ -89,7 +89,8 @@ describe('Stripe utils - transformStripeSubscriptionToData', () => { metadata: { workspaceUuid: 'ws-123', subscriptionType: 'common', - subscriptionPlan: 'pro' + subscriptionPlan: 'pro', + accountUuid: 'acc-123' }, customer: { id: 'cus_123', @@ -126,7 +127,7 @@ describe('Stripe utils - transformStripeSubscriptionToData', () => { expect.objectContaining({ id: 'stripe_sub_123', workspaceUuid: 'ws-123', - accountUuid: 'cus_123', + accountUuid: 'acc-123', providerSubscriptionId: 'sub_123', type: 'common', plan: 'pro', @@ -182,7 +183,8 @@ describe('Stripe utils - transformStripeSubscriptionToData', () => { metadata: { workspaceUuid: 'ws-123', subscriptionType: 'common', - subscriptionPlan: 'pro' + subscriptionPlan: 'pro', + accountUuid: 'acc-123' }, customer: 'cus_123' as any, items: { diff --git a/services/payment/pod-payment/src/providers/stripe/provider.ts b/services/payment/pod-payment/src/providers/stripe/provider.ts index 5ac46328ac..1eb79d0f23 100644 --- a/services/payment/pod-payment/src/providers/stripe/provider.ts +++ b/services/payment/pod-payment/src/providers/stripe/provider.ts @@ -72,7 +72,7 @@ export class StripeProvider implements PaymentProvider { this.stripe = new StripeClient(apiKey) this.webhookSecret = webhookSecret // TODO: support branding - this.frontUrl = frontUrl + this.frontUrl = frontUrl.replace(/\/+$/, '') this.subscriptionPlans = {} this.accountClient = accountClient const plans = subscriptionPlans.split(';') @@ -294,7 +294,8 @@ export class StripeProvider implements PaymentProvider { ctx: MeasureContext, subscriptionId: string, newPlan: string, - workspaceUrl: string + workspaceUrl: string, + accountUuid: string ): Promise { // Get the current subscription to check if it's free const currentSub = await this.stripe.getSubscription(ctx, subscriptionId) @@ -327,7 +328,8 @@ export class StripeProvider implements PaymentProvider { metadata: { workspaceUuid: metadata.workspaceUuid, subscriptionType: SubscriptionType.Tier, - subscriptionPlan: newPlan + subscriptionPlan: newPlan, + accountUuid } }) diff --git a/services/payment/pod-payment/src/providers/stripe/utils.ts b/services/payment/pod-payment/src/providers/stripe/utils.ts index e9111123dd..409066341c 100644 --- a/services/payment/pod-payment/src/providers/stripe/utils.ts +++ b/services/payment/pod-payment/src/providers/stripe/utils.ts @@ -46,22 +46,26 @@ function mapStripeStatus (stripeStatus: Stripe.Subscription.Status): Subscriptio export function transformStripeSubscriptionToData (subscription: Stripe.Subscription): SubscriptionData | null { const metadata = subscription.metadata ?? {} const workspaceUuid = metadata.workspaceUuid as WorkspaceUuid | undefined - let accountUuid: AccountUuid | undefined const subscriptionType = metadata.subscriptionType as SubscriptionType | undefined const subscriptionPlan = metadata.subscriptionPlan as string | undefined + // accountUuid is set in subscription_data.metadata when creating checkout; prefer subscription.metadata + let accountUuid: AccountUuid | undefined = metadata.accountUuid as AccountUuid | undefined + if ( + accountUuid === undefined && + typeof subscription.customer !== 'string' && + subscription.customer !== null && + subscription.customer.deleted !== true + ) { + accountUuid = subscription.customer.metadata?.accountUuid as AccountUuid | undefined + } + // For Stripe, customer can be a string ID or expanded Customer object let customerId: string | undefined if (typeof subscription.customer === 'string') { customerId = subscription.customer } else if (subscription.customer !== null && subscription.customer.deleted !== true) { - // subscription.customer is Customer (not DeletedCustomer) customerId = subscription.customer.id - accountUuid = subscription.customer.id as AccountUuid - // Try to get accountUuid from customer metadata if not in subscription metadata - if (subscription.customer.metadata?.accountUuid !== undefined) { - accountUuid = subscription.customer.metadata.accountUuid as AccountUuid - } } if ( diff --git a/services/payment/pod-payment/src/server.ts b/services/payment/pod-payment/src/server.ts index 180c4d4bb4..2b27fca4df 100644 --- a/services/payment/pod-payment/src/server.ts +++ b/services/payment/pod-payment/src/server.ts @@ -412,6 +412,12 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis return } + const accountUuid = subscription.accountUuid ?? req.token?.account + if (accountUuid == null) { + res.status(400).json({ error: 'Missing account, cannot update plan' }) + return + } + let updateResult: SubscriptionData | CheckoutResponse | null try { @@ -420,7 +426,8 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis ctx, subscription.providerSubscriptionId, plan, - loginInfo.workspaceUrl + loginInfo.workspaceUrl, + accountUuid ) } catch (err) { ctx.error('Failed to update subscription at provider', { err }) From 81cf17ab5d969c7f9aa775089cb2ccfa538592c4 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Tue, 10 Mar 2026 12:38:37 +0500 Subject: [PATCH 026/128] Hide child section for card Signed-off-by: Denis Bykhov --- models/card/src/actions.ts | 18 +++++++ models/card/src/index.ts | 3 +- models/card/src/plugin.ts | 6 ++- .../src/components/Childs.svelte | 52 +++---------------- plugins/card-resources/src/index.ts | 8 ++- plugins/card-resources/src/plugin.ts | 5 +- plugins/card-resources/src/utils.ts | 51 +++++++++++++++++- 7 files changed, 91 insertions(+), 52 deletions(-) diff --git a/models/card/src/actions.ts b/models/card/src/actions.ts index ccc78c823c..c948a572a8 100644 --- a/models/card/src/actions.ts +++ b/models/card/src/actions.ts @@ -278,4 +278,22 @@ export function createActions (builder: Builder): void { }, card.action.Duplicate ) + + createAction( + builder, + { + action: card.actionImpl.CreateChild, + label: card.string.CreateChild, + icon: view.icon.Add, + input: 'focus', + category: card.category.Card, + target: card.class.Card, + context: { + mode: ['context', 'browser'], + application: card.app.Card, + group: 'edit' + } + }, + card.action.CreateChild + ) } diff --git a/models/card/src/index.ts b/models/card/src/index.ts index d2233ca7be..0b719750c3 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -1053,7 +1053,8 @@ function defineTabs (builder: Builder): void { label: card.string.Children, component: card.sectionComponent.ChildrenSection, order: 400, - navigation: [] + navigation: [], + checkVisibility: card.function.CheckChildrenSectionVisibility }, card.section.Children ) diff --git a/models/card/src/plugin.ts b/models/card/src/plugin.ts index 1aff4c7168..464e6f8946 100644 --- a/models/card/src/plugin.ts +++ b/models/card/src/plugin.ts @@ -31,14 +31,16 @@ export default mergeIds(cardId, card, { actionImpl: { DeleteMasterTag: '' as ViewAction, DuplicateCard: '' as ViewAction, - EditSpace: '' as ViewAction + EditSpace: '' as ViewAction, + CreateChild: '' as ViewAction }, action: { DeleteMasterTag: '' as Ref, SetParent: '' as Ref>, UnsetParent: '' as Ref>, PublicLink: '' as Ref>, - Duplicate: '' as Ref + Duplicate: '' as Ref, + CreateChild: '' as Ref }, category: { Card: '' as Ref, diff --git a/plugins/card-resources/src/components/Childs.svelte b/plugins/card-resources/src/components/Childs.svelte index 1636787c89..d26886b544 100644 --- a/plugins/card-resources/src/components/Childs.svelte +++ b/plugins/card-resources/src/components/Childs.svelte @@ -13,14 +13,11 @@ // limitations under the License. --> - - -
+
Date: Wed, 11 Mar 2026 11:21:55 +0530 Subject: [PATCH 032/128] feat(communication): improve messages loading state accessibility (#10613) Signed-off-by: SaiVaraprasad Medapati --- .../src/components/message/MessagesLoading.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/communication-resources/src/components/message/MessagesLoading.svelte b/plugins/communication-resources/src/components/message/MessagesLoading.svelte index bbb7605026..ac8b15ac90 100644 --- a/plugins/communication-resources/src/components/message/MessagesLoading.svelte +++ b/plugins/communication-resources/src/components/message/MessagesLoading.svelte @@ -13,12 +13,13 @@ // limitations under the License. --> -
+
+
@@ -28,6 +29,7 @@ display: flex; align-items: center; justify-content: center; + gap: 0.5rem; color: var(--global-secondary-TextColor); font-weight: 500; height: 5rem; From d7cde12607290c2e7bf1fd8be0388c320c179543 Mon Sep 17 00:00:00 2001 From: SaiVaraprasad Medapati Date: Wed, 11 Mar 2026 11:22:28 +0530 Subject: [PATCH 033/128] feat(notification): add inbox settings keyboard shortcuts (#10614) Signed-off-by: SaiVaraprasad Medapati --- .../src/components/inbox/SettingsPopup.svelte | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/plugins/notification-resources/src/components/inbox/SettingsPopup.svelte b/plugins/notification-resources/src/components/inbox/SettingsPopup.svelte index cea6e97097..bef20dd509 100644 --- a/plugins/notification-resources/src/components/inbox/SettingsPopup.svelte +++ b/plugins/notification-resources/src/components/inbox/SettingsPopup.svelte @@ -36,26 +36,31 @@ if (key.code === 'ArrowUp') { key.stopPropagation() key.preventDefault() - list.select(selection - 1) + selectIndex(selection - 1) return true } if (key.code === 'ArrowDown') { key.stopPropagation() key.preventDefault() - list.select(selection + 1) + selectIndex(selection + 1) return true } - if (key.code === 'Enter') { + if (key.code === 'Home') { + key.stopPropagation() + key.preventDefault() + selectIndex(0) + return true + } + if (key.code === 'End') { + key.stopPropagation() + key.preventDefault() + selectIndex(items.length - 1) + return true + } + if (key.code === 'Enter' || key.code === 'Space') { key.preventDefault() key.stopPropagation() - items[selection]?.onToggle() - items = items.map((item, index) => { - if (index === selection) { - return { ...item, on: !item.on } - } - - return item - }) + toggleSelectedItem() return true } return false @@ -66,16 +71,36 @@ $: if (popupElement) { popupElement.focus() } + + function selectIndex (index: number): void { + if (items.length === 0) return + + list.select(Math.max(0, Math.min(index, items.length - 1))) + } + + function toggleSelectedItem (): void { + const item = items[selection] + if (item === undefined) return + + item.onToggle() + items = items.map((settingItem, index) => { + if (index === selection) { + return { ...settingItem, on: !settingItem.on } + } + + return settingItem + }) + } - - + {/if} + {:else} + {#if error} +
+ +
+ {/if} +
+
+ +
+
+ +
+
+ +
+
+ +
+
{/if} -
-
- -
-
- -
-
- -
-
- -
-
+ + diff --git a/server/account/lang/cs.json b/server/account/lang/cs.json index f5c0bbe352..52d39af4aa 100644 --- a/server/account/lang/cs.json +++ b/server/account/lang/cs.json @@ -14,6 +14,9 @@ "OtpSubject": "{app}: potvrzovací kód {code}", "ResendInviteText": "Byli jste znovu pozváni do {ws}. Použijte prosím následující odkaz pro připojení: {link}. Odkaz pro opětovné pozvání je platný po dobu {expHours} hodin.", "ResendInviteHTML": "

Byli jste znovu pozváni do {ws}. Pro připojení klikněte na následující odkaz: Připojit se

Pokud výše uvedený odkaz nefunguje, vložte následující odkaz do adresního řádku prohlížeče: {link}

Odkaz pro opětovné pozvání je platný po dobu {expHours} hodin.

", - "ResendInviteSubject": "Opětovné pozvání do {ws}" + "ResendInviteSubject": "Opětovné pozvání do {ws}", + "PasswordSetupText": "Nastavení hesla pro váš účet\n\nPožádali jste o přidání přihlašování heslem ke svému účtu.\n\nNastavte si heslo zde:\n{link}\n\nPo nastavení se budete moci přihlašovat pomocí e-mailu a hesla kromě stávajícího způsobu přihlášení.\n\nPokud jste o toto nepožádali, můžete tento e-mail bezpečně ignorovat.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Nastavte si heslo pro svůj účet Huly" } } diff --git a/server/account/lang/de.json b/server/account/lang/de.json index da8fdfd18b..f1afe59a31 100644 --- a/server/account/lang/de.json +++ b/server/account/lang/de.json @@ -14,6 +14,9 @@ "OtpSubject": "{app} Bestätigungscode: {code}", "ResendInviteText": "Sie wurden erneut zu {ws} eingeladen. Bitte verwenden Sie den folgenden Link zum Beitreten: {link}. Der erneute Einladungslink ist {expHours} Stunden gültig.", "ResendInviteHTML": "

Sie wurden erneut zu {ws} eingeladen. Um beizutreten, klicken Sie bitte auf den folgenden Link: Beitreten

Falls der Einladungslink nicht funktioniert, fügen Sie bitte den folgenden Link in die Adresszeile Ihres Browsers ein: {link}

Der erneute Einladungslink ist {expHours} Stunden gültig.

", - "ResendInviteSubject": "Erneute Einladung zu {ws}" + "ResendInviteSubject": "Erneute Einladung zu {ws}", + "PasswordSetupText": "Passwort für Ihr Konto festlegen\n\nSie haben angefordert, die Anmeldung mit Passwort zu Ihrem Konto hinzuzufügen.\n\nLegen Sie Ihr Passwort hier fest:\n{link}\n\nNach der Einrichtung können Sie sich zusätzlich zu Ihrer bestehenden Anmeldemethode mit E-Mail und Passwort anmelden.\n\nFalls Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Passwort für Ihr Huly-Konto festlegen" } } diff --git a/server/account/lang/en.json b/server/account/lang/en.json index fffb1aab48..21fde747e7 100644 --- a/server/account/lang/en.json +++ b/server/account/lang/en.json @@ -1,19 +1,22 @@ { "string": { - "ConfirmationText": "Thank you for your interest in {name}. To complete the sign up process, please paste the following link into your browser's URL bar: {link}. Regards, {name} Team.", - "ConfirmationHTML": "

Hello,

Thank you for your interest in {name}. To complete the sign up process, please click this link or paste the following link into your browser's URL bar.

{link}

Regards,

{name} Team.

", + "ConfirmationText": "Confirm your email address\n\nThanks for signing up for {name}. To complete the sign-up process, please paste the following link into your browser:\n\n{link}\n\nIf you didn't sign up for {name}, you can safely ignore this email.", + "ConfirmationHTML": "
Huly

Confirm your email address

Thanks for signing up for {name}. To complete the sign-up process, please confirm your email address.

Confirm email →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't sign up for {name}, you can safely ignore this email.

© Huly — All rights reserved

", "ConfirmationSubject": "Confirm your email address to sign up for {name}", - "RecoveryText": "We received a request to reset the password for your account. To reset your password, please paste the following link into your browser's URL bar: {link}. If you have not requested a password reset, please ignore this email.", - "RecoveryHTML": "

We received a request to reset the password for your account. To reset your password, please click the link below: Reset password

If the reset password link above does not work, paste the following link into your browser's URL bar: {link}

If you have not requested a password reset, please ignore this email.

", + "RecoveryText": "Password Recovery\n\nWe received a request to reset the password for your account.\n\nReset your password here:\n{link}\n\nIf you didn't request a password reset, you can safely ignore this email.", + "RecoveryHTML": "
Huly

Reset your password

We received a request to reset the password for your account. Click the button below to set a new password.

Reset password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request a password reset, you can safely ignore this email. Your password won't change.

© Huly — All rights reserved

", "RecoverySubject": "Password recovery", - "InviteText": "You were invited to {ws}. To join, please paste the following link into your browser's URL bar: {link}. The link is valid for {expHours} hours.", - "InviteHTML": "

You were invited to {ws}. To join, please click the link below: Join

If the invite link above does not work, paste the following link into your browser's URL bar: {link}

The link is valid for {expHours} hours.

", + "InviteText": "You've been invited to {ws}\n\nYou've been invited to join the {ws} workspace on Huly.\n\nAccept your invitation here:\n{link}\n\nThis invite link expires in {expHours} hours.\n\nIf you weren't expecting an invitation, you can safely ignore this email.", + "InviteHTML": "
Huly

You've been invited to {ws}

You've been invited to join the {ws} workspace on Huly. Click the button below to accept and get started.

Join {ws} →

Button not working? Copy and paste this link into your browser:
{link}

This invite link expires in {expHours} hours. If you weren't expecting an invitation, you can safely ignore this email.

© Huly — All rights reserved

", "InviteSubject": "Invitation to {ws}", - "OtpText": "Confirm your email address to access {app}!\n\nYour confirmation code is below - enter it in the window where you've started signing in for {app}.\n\n{code}\n\nIf you didn’t request this email, there’s nothing to worry about — you can safely ignore it.", - "OtpHTML": "

Confirm your email address to access {app}!

Your confirmation code is below - enter it in the window where you've started signing in for {app}.


{code}

If you didn’t request this email, there’s nothing to worry about — you can safely ignore it.

", + "OtpText": "Your {app} sign-in code\n\nYour verification code:\n\n{code}\n\nEnter this code in the sign-in window to continue.\n\nIf you didn't request this code, you can safely ignore this email.", + "OtpHTML": "
Huly

Your sign-in code

Your verification code for {app}. Enter it in the sign-in window to continue.

{code}

This code expires shortly. If you didn't request this code, you can safely ignore this email.

© Huly — All rights reserved

", "OtpSubject": "{app} confirmation code: {code}", - "ResendInviteText": "You have been re-invited to {ws}. Please use the following link to join: {link}. The re-invitation link is valid for {expHours} hours.", - "ResendInviteHTML": "

You have been re-invited to {ws}. To join, click the link below: Join

If the invite link above does not work, paste the following link into your browser's URL bar: {link}

The re-invitation link is valid for {expHours} hours.

", - "ResendInviteSubject": "Re-invitation to {ws}" + "ResendInviteText": "You've been re-invited to {ws}\n\nYour invitation to the {ws} workspace on Huly has been renewed.\n\nAccept your invitation here:\n{link}\n\nThis invite link expires in {expHours} hours.", + "ResendInviteHTML": "
Huly

You've been re-invited to {ws}

Your invitation to the {ws} workspace on Huly has been renewed. Click the button below to accept.

Join {ws} →

Button not working? Copy and paste this link into your browser:
{link}

This invite link expires in {expHours} hours. If you weren't expecting this, you can safely ignore this email.

© Huly — All rights reserved

", + "ResendInviteSubject": "Re-invitation to {ws}", + "PasswordSetupText": "Set a password for your account\n\nYou requested to add password sign-in to your account.\n\nSet your password here:\n{link}\n\nOnce set, you can sign in with email + password in addition to your existing sign-in method.\n\nIf you didn't request this, you can safely ignore this email.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Set a password for your Huly account" } } diff --git a/server/account/lang/es.json b/server/account/lang/es.json index 9864e78095..a4e4b13f4d 100644 --- a/server/account/lang/es.json +++ b/server/account/lang/es.json @@ -14,6 +14,9 @@ "OtpSubject": "Código de confirmación de {app}: {code}", "ResendInviteText": "Has sido reinvitado a {ws}. Por favor, utiliza el siguiente enlace para unirte: {link}. El enlace de reinvitación es válido por {expHours} horas.", "ResendInviteHTML": "

Has sido reinvitado a {ws}. Para unirte, haz clic en el enlace de abajo: Unirse

Si el enlace de invitación anterior no funciona, pega el siguiente enlace en la barra de direcciones de tu navegador: {link}

El enlace de reinvitación es válido por {expHours} horas.

", - "ResendInviteSubject": "Reinvitación a {ws}" + "ResendInviteSubject": "Reinvitación a {ws}", + "PasswordSetupText": "Establece una contraseña para tu cuenta\n\nSolicitaste agregar el inicio de sesión con contraseña a tu cuenta.\n\nEstablece tu contraseña aquí:\n{link}\n\nUna vez establecida, podrás iniciar sesión con correo electrónico y contraseña además de tu método de inicio de sesión actual.\n\nSi no solicitaste esto, puedes ignorar este mensaje.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Establece una contraseña para tu cuenta de Huly" } } diff --git a/server/account/lang/fr.json b/server/account/lang/fr.json index fed63eefe6..127c28fa28 100644 --- a/server/account/lang/fr.json +++ b/server/account/lang/fr.json @@ -14,6 +14,9 @@ "OtpSubject": "Code de confirmation {app} : {code}", "ResendInviteText": "Vous avez été réinvité à {ws}. Veuillez utiliser le lien suivant pour rejoindre : {link}. Le lien de réinvitation est valide pendant {expHours} heures.", "ResendInviteHTML": "

Vous avez été réinvité à {ws}. Pour rejoindre, cliquez sur le lien ci-dessous : Rejoindre

Si le lien d'invitation ci-dessus ne fonctionne pas, collez le lien suivant dans la barre d'URL de votre navigateur : {link}

Le lien de réinvitation est valide pendant {expHours} heures.

", - "ResendInviteSubject": "Réinvitation à {ws}" + "ResendInviteSubject": "Réinvitation à {ws}", + "PasswordSetupText": "Définir un mot de passe pour votre compte\n\nVous avez demandé à ajouter la connexion par mot de passe à votre compte.\n\nDéfinissez votre mot de passe ici :\n{link}\n\nUne fois défini, vous pourrez vous connecter avec votre adresse e-mail et votre mot de passe en plus de votre méthode de connexion existante.\n\nSi vous n'avez pas fait cette demande, vous pouvez ignorer cet e-mail en toute sécurité.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Définir un mot de passe pour votre compte Huly" } } diff --git a/server/account/lang/it.json b/server/account/lang/it.json index 210bb742af..b787aecbf3 100644 --- a/server/account/lang/it.json +++ b/server/account/lang/it.json @@ -14,6 +14,9 @@ "OtpSubject": "Codice di conferma {app}: {code}", "ResendInviteText": "Sei stato reinvitato a {ws}. Utilizza il seguente link per unirti: {link}. Il link di reinvito è valido per {expHours} ore.", "ResendInviteHTML": "

Sei stato reinvitato a {ws}. Per unirti, fai clic sul link sottostante: Unisciti

Se il link di invito sopra non funziona, incolla il seguente link nella barra degli URL del tuo browser: {link}

Il link di reinvito è valido per {expHours} ore.

", - "ResendInviteSubject": "Reinvito a {ws}" + "ResendInviteSubject": "Reinvito a {ws}", + "PasswordSetupText": "Imposta una password per il tuo account\n\nHai richiesto di aggiungere l'accesso con password al tuo account.\n\nImposta la tua password qui:\n{link}\n\nUna volta impostata, potrai accedere con e-mail e password in aggiunta al tuo metodo di accesso esistente.\n\nSe non hai richiesto questo, puoi ignorare questa email in tutta sicurezza.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Imposta una password per il tuo account Huly" } } diff --git a/server/account/lang/pt-br.json b/server/account/lang/pt-br.json index 02749364f0..faaa4aa41e 100644 --- a/server/account/lang/pt-br.json +++ b/server/account/lang/pt-br.json @@ -14,6 +14,9 @@ "OtpSubject": "Código de confirmação {app}: {code}", "ResendInviteText": "Você foi convidado novamente para {ws}. Por favor, use o link a seguir para entrar: {link}. O link de convite é válido por {expHours} horas.", "ResendInviteHTML": "

Você foi convidado novamente para {ws}. Para se juntar, clique no link abaixo: Entrar

Se o link de convite acima não funcionar, cole o seguinte link na barra de URL do seu navegador: {link}

O link de convite é válido por {expHours} horas.

", - "ResendInviteSubject": "Convidar novamente para {ws}" + "ResendInviteSubject": "Convidar novamente para {ws}", + "PasswordSetupText": "Defina uma senha para a sua conta\n\nVocê solicitou adicionar o login com senha à sua conta.\n\nDefina sua senha aqui:\n{link}\n\nApós definida, você poderá fazer login com e-mail e senha além do seu método de login atual.\n\nSe você não solicitou isso, pode ignorar este e-mail com segurança.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Defina uma senha para a sua conta Huly" } } diff --git a/server/account/lang/pt.json b/server/account/lang/pt.json index a2d03aa3ff..a8af638923 100644 --- a/server/account/lang/pt.json +++ b/server/account/lang/pt.json @@ -14,6 +14,9 @@ "OtpSubject": "Código de confirmação {app}: {code}", "ResendInviteText": "Você foi reinvitado para {ws}. Por favor, use o link a seguir para se juntar: {link}. O link de reinvitação é válido por {expHours} horas.", "ResendInviteHTML": "

Você foi reinvitado para {ws}. Para se juntar, clique no link abaixo: Entrar

Se o link de convite acima não funcionar, cole o seguinte link na barra de URL do seu navegador: {link}

O link de reinvitação é válido por {expHours} horas.

", - "ResendInviteSubject": "Reconvite para {ws}" + "ResendInviteSubject": "Reconvite para {ws}", + "PasswordSetupText": "Defina uma palavra-passe para a sua conta\n\nSolicitou a adição do início de sessão com palavra-passe à sua conta.\n\nDefina a sua palavra-passe aqui:\n{link}\n\nApós definida, poderá iniciar sessão com e-mail e palavra-passe além do seu método de início de sessão existente.\n\nSe não solicitou isto, pode ignorar este e-mail com segurança.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Defina uma palavra-passe para a sua conta Huly" } } diff --git a/server/account/lang/ru.json b/server/account/lang/ru.json index b0fb0f0353..a48b55e9f6 100644 --- a/server/account/lang/ru.json +++ b/server/account/lang/ru.json @@ -14,6 +14,9 @@ "OtpSubject": "Код подтверждения {app}: {code}", "ResendInviteText": "Вы были повторно приглашены в {ws}. Пожалуйста, используйте следующую ссылку для присоединения: {link}. Ссылка повторного приглашения действительна в течение {expHours} часов.", "ResendInviteHTML": "

Вы были повторно приглашены в {ws}. Чтобы присоединиться, нажмите на ссылку ниже: Присоединиться

Если ссылка выше не работает, вставьте следующую ссылку в адресную строку вашего браузера: {link}

Ссылка повторного приглашения действительна в течение {expHours} часов.

", - "ResendInviteSubject": "Повторное приглашение в {ws}" + "ResendInviteSubject": "Повторное приглашение в {ws}", + "PasswordSetupText": "Установите пароль для вашей учётной записи\n\nВы запросили добавление входа с паролем к вашей учётной записи.\n\nУстановите пароль здесь:\n{link}\n\nПосле установки вы сможете входить с помощью электронной почты и пароля в дополнение к существующему способу входа.\n\nЕсли вы не запрашивали это, просто проигнорируйте это письмо.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Установите пароль для вашей учётной записи Huly" } } diff --git a/server/account/lang/tr.json b/server/account/lang/tr.json index 1864a20c5d..ce2a5e4bf7 100644 --- a/server/account/lang/tr.json +++ b/server/account/lang/tr.json @@ -14,6 +14,9 @@ "OtpSubject": "{app} doğrulama kodu: {code}", "ResendInviteText": "{ws} çalışma alanına yeniden davet edildiniz. Katılmak için lütfen şu bağlantıyı kullanın: {link}. Yeniden davet bağlantısı {expHours} saat geçerlidir.", "ResendInviteHTML": "

{ws} çalışma alanına yeniden davet edildiniz. Katılmak için aşağıdaki bağlantıya tıklayın: Katıl

Yukarıdaki davet bağlantısı çalışmıyorsa, şu bağlantıyı tarayıcınızın adres çubuğuna yapıştırın: {link}

Yeniden davet bağlantısı {expHours} saat geçerlidir.

", - "ResendInviteSubject": "{ws} çalışma alanına yeniden davet" + "ResendInviteSubject": "{ws} çalışma alanına yeniden davet", + "PasswordSetupText": "Hesabınız için bir şifre belirleyin\n\nHesabınıza şifre ile giriş eklenmesini talep ettiniz.\n\nŞifrenizi buradan belirleyin:\n{link}\n\nBelirledikten sonra, mevcut giriş yönteminize ek olarak e-posta ve şifre ile giriş yapabilirsiniz.\n\nEğer bu talebi siz yapmadıysanız, bu e-postayı güvenle görmezden gelebilirsiniz.", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "Huly hesabınız için bir şifre belirleyin" } } diff --git a/server/account/lang/zh.json b/server/account/lang/zh.json index 9a24635eaa..060a12ba92 100644 --- a/server/account/lang/zh.json +++ b/server/account/lang/zh.json @@ -14,6 +14,9 @@ "OtpSubject": "{app} 确认码:{code}", "ResendInviteText": "您已被重新邀请加入 {ws}。请使用以下链接加入:{link}。重新邀请链接的有效期为 {expHours} 小时。", "ResendInviteHTML": "

您已被重新邀请加入 {ws}。要加入,请点击以下链接:加入

如果上方的邀请链接无效,请将以下链接粘贴到您的浏览器地址栏中:{link}

重新邀请链接的有效期为 {expHours} 小时。

", - "ResendInviteSubject": "重新邀请加入 {ws}" + "ResendInviteSubject": "重新邀请加入 {ws}", + "PasswordSetupText": "为您的账户设置密码\n\n您请求为账户添加密码登录方式。\n\n在此设置密码:\n{link}\n\n设置完成后,您可以使用电子邮件和密码登录,同时保留现有的登录方式。\n\n如果您没有发起此请求,可以放心地忽略这封邮件。", + "PasswordSetupHTML": "
Huly

Set a password for your account

You requested to add password sign-in to your account. Click the button below to choose your password.

Once set, you can sign in with email + password in addition to your existing sign-in method.

Set password →

Button not working? Copy and paste this link into your browser:
{link}

If you didn't request this, you can safely ignore this email.

© Huly — All rights reserved

", + "PasswordSetupSubject": "为您的 Huly 账户设置密码" } } diff --git a/server/account/src/__tests__/ssoPassword.test.ts b/server/account/src/__tests__/ssoPassword.test.ts new file mode 100644 index 0000000000..0c00f71c5b --- /dev/null +++ b/server/account/src/__tests__/ssoPassword.test.ts @@ -0,0 +1,285 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { SocialIdType, type MeasureContext, type PersonUuid } from '@hcengineering/core' +import platform, { getMetadata } from '@hcengineering/platform' +import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token' + +import { accountPlugin } from '../plugin' +import { type AccountDB } from '../types' +import { getMethods } from '../operations' + +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + ...actual.default, + getMetadata: jest.fn(), + translate: jest.fn((id: string, params: any) => `${id} << ${JSON.stringify(params)}`) + } +}) + +jest.mock('@hcengineering/server-token', () => { + class TokenError extends Error { + constructor (msg: string) { + super(msg) + this.name = 'TokenError' + } + } + return { + decodeTokenVerbose: jest.fn(), + decodeToken: jest.fn(), + TokenError, + generateToken: jest.fn().mockReturnValue('mocked-reset-token') + } +}) + +const mockCtx = { error: jest.fn(), info: jest.fn(), warn: jest.fn() } as unknown as MeasureContext +const accountUuid = 'account-uuid' as PersonUuid + +describe('checkHasPassword', () => { + const mockDb = { + account: { findOne: jest.fn() } + } as unknown as AccountDB + + const methods = getMethods() + const checkHasPassword = methods.checkHasPassword as NonNullable + + beforeEach(() => { + jest.clearAllMocks() + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: accountUuid }) + }) + + test('returns true when account has hash and salt', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: Buffer.from('hash'), + salt: Buffer.from('salt') + }) + const result = await checkHasPassword(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.result).toBe(true) + }) + + test('returns false when account has no hash (SSO-only)', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: null, + salt: null + }) + const result = await checkHasPassword(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.result).toBe(false) + }) + + test('returns false for partial state: hash set but salt null', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: Buffer.from('hash'), + salt: null + }) + const result = await checkHasPassword(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.result).toBe(false) + }) + + test('returns false for partial state: salt set but hash null', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: null, + salt: Buffer.from('salt') + }) + const result = await checkHasPassword(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.result).toBe(false) + }) + + test('returns error for missing account', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null) + const result = await checkHasPassword(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.error).toBeDefined() + }) + + test('returns error for invalid/expired token', async () => { + const { TokenError } = jest.requireMock('@hcengineering/server-token') + ;(decodeTokenVerbose as jest.Mock).mockImplementation(() => { + throw new TokenError('invalid token') + }) + const result = await checkHasPassword(mockCtx, mockDb, null, { id: 1, params: {} }, 'bad-token') + expect(result.error).toBeDefined() + }) +}) + +describe('changePassword', () => { + const mockDb = { + account: { findOne: jest.fn() }, + accountEvent: { insertOne: jest.fn() }, + setPassword: jest.fn() + } as unknown as AccountDB + + const methods = getMethods() + const changePassword = methods.changePassword as NonNullable + + beforeEach(() => { + jest.clearAllMocks() + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: accountUuid }) + }) + + test('rejects empty newPassword', async () => { + const result = await changePassword( + mockCtx, + mockDb, + null, + { id: 1, params: { oldPassword: 'old', newPassword: '' } }, + 'token' + ) + expect(result.error).toBeDefined() + expect(mockDb.setPassword).not.toHaveBeenCalled() + }) + + test('rejects empty oldPassword even for SSO-only accounts (no hash)', async () => { + // changePassword always requires oldPassword — SSO accounts must use requestPasswordSetup flow + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: null, + salt: null + }) + const result = await changePassword( + mockCtx, + mockDb, + null, + { id: 1, params: { oldPassword: '', newPassword: 'newpass123' } }, + 'token' + ) + expect(result.error).toBeDefined() + expect(mockDb.setPassword).not.toHaveBeenCalled() + }) + + test('rejects empty oldPassword for accounts with hash', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: Buffer.from('hash'), + salt: Buffer.from('salt') + }) + const result = await changePassword( + mockCtx, + mockDb, + null, + { id: 1, params: { oldPassword: '', newPassword: 'newpass123' } }, + 'token' + ) + expect(result.error).toBeDefined() + expect(mockDb.setPassword).not.toHaveBeenCalled() + }) + + test('rejects wrong oldPassword', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: Buffer.from('hash'), + salt: Buffer.from('salt') + }) + const result = await changePassword( + mockCtx, + mockDb, + null, + { id: 1, params: { oldPassword: 'wrongpass', newPassword: 'newpass123' } }, + 'token' + ) + expect(result.error).toBeDefined() + expect(mockDb.setPassword).not.toHaveBeenCalled() + }) +}) + +describe('requestPasswordSetup', () => { + const mockDb = { + account: { findOne: jest.fn() }, + socialId: { findOne: jest.fn() } + } as unknown as AccountDB + + const methods = getMethods() + const requestPasswordSetup = methods.requestPasswordSetup as NonNullable + + const mockFetch = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: accountUuid }) + ;(generateToken as jest.Mock).mockReturnValue('mocked-reset-token') + ;(getMetadata as jest.Mock).mockImplementation((key: any) => { + switch (key) { + case accountPlugin.metadata.MAIL_URL: + return 'http://mail.test' + case accountPlugin.metadata.MAIL_AUTH_TOKEN: + return undefined + case accountPlugin.metadata.FrontURL: + return 'http://app.test' + default: + return undefined + } + }) + global.fetch = mockFetch + mockFetch.mockResolvedValue({ ok: true }) + // Default: SSO-only account (no password hash) + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ uuid: accountUuid, hash: null, salt: null }) + }) + + test('rejects when account already has a password (server-side guard)', async () => { + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ + uuid: accountUuid, + hash: Buffer.from('hash'), + salt: Buffer.from('salt') + }) + const result = await requestPasswordSetup(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.error).toBeDefined() + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('sends email when account has a verified email social ID', async () => { + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ + type: SocialIdType.EMAIL, + value: 'user@example.com', + personUuid: accountUuid + }) + + const result = await requestPasswordSetup(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + + expect(result.error).toBeUndefined() + expect(mockFetch).toHaveBeenCalledTimes(1) + const fetchCall = mockFetch.mock.calls[0] + const body = JSON.parse(fetchCall[1].body) + expect(body.to).toBe('user@example.com') + expect(body.text).toContain('mocked-reset-token') + }) + + test('returns SocialIdNotFound when account has no email social ID', async () => { + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + + const result = await requestPasswordSetup(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + + expect(result.error).toBeDefined() + expect(result.error?.code).toBe(platform.status.SocialIdNotFound) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('does not throw when mail service returns non-ok response', async () => { + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ + type: SocialIdType.EMAIL, + value: 'user@example.com', + personUuid: accountUuid + }) + mockFetch.mockResolvedValue({ ok: false, statusText: 'Internal Server Error' }) + + // Should complete without throwing — error is logged, not rethrown + const result = await requestPasswordSetup(mockCtx, mockDb, null, { id: 1, params: {} }, 'token') + expect(result.error).toBeUndefined() + expect(mockCtx.error).toHaveBeenCalled() + }) +}) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 471ce15ad9..be19c3a3f5 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -1310,6 +1310,24 @@ export async function confirm ( return result } +/** + * Checks whether the authenticated account has a password set. + * SSO-only accounts (Google, GitHub, OIDC) have no password hash. + */ +export async function checkHasPassword ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string +): Promise { + const { account: accountUuid } = decodeTokenVerbose(ctx, token) + const account = await getAccount(db, accountUuid) + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: accountUuid })) + } + return account.hash != null && account.salt != null +} + export async function changePassword ( ctx: MeasureContext, db: AccountDB, @@ -1417,6 +1435,72 @@ export async function requestPasswordReset ( } } +/** + * Sends a password-setup email to an SSO-only account so they can add + * email+password as a secondary sign-in method. + * + * Requires authentication (session token). Only valid for accounts that have + * no password set — accounts with an existing password must use changePassword. + */ +export async function requestPasswordSetup ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string +): Promise { + const { account: accountUuid } = decodeTokenVerbose(ctx, token) + + // Guard: reject if the account already has a password. The setup flow + // bypasses the old-password requirement in changePassword, so it must only + // be accessible to accounts that have no password yet. + const existingAccount = await getAccount(db, accountUuid) + if (existingAccount?.hash != null && existingAccount?.salt != null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + ctx.info('Requesting password setup', { accountUuid }) + + const emailSocialId = await db.socialId.findOne({ + type: SocialIdType.EMAIL, + personUuid: accountUuid + }) + + if (emailSocialId == null) { + ctx.error('Email social id not found for account', { accountUuid }) + throw new PlatformError( + new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: '', type: SocialIdType.EMAIL }) + ) + } + + const { mailURL, mailAuth } = getMailUrl() + const front = getFrontUrl(branding) + const resetToken = generateToken(accountUuid, undefined, { restoreEmail: emailSocialId.value }) + const link = concatLink(front, `/login/recovery?id=${resetToken}`) + const lang = branding?.language + const text = await translate(accountPlugin.string.PasswordSetupText, { link }, lang) + const html = await translate(accountPlugin.string.PasswordSetupHTML, { link }, lang) + const subject = await translate(accountPlugin.string.PasswordSetupSubject, {}, lang) + + const response = await fetch(concatLink(mailURL, '/send'), { + method: 'post', + headers: { + 'Content-Type': 'application/json', + ...(mailAuth != null ? { Authorization: `Bearer ${mailAuth}` } : {}) + }, + body: JSON.stringify({ + text, + html, + subject, + to: emailSocialId.value + }) + }) + if (response.ok) { + ctx.info('Password setup email sent', { accountUuid }) + } else { + ctx.error(`Failed to send password setup email: ${response.statusText}`, { accountUuid }) + } +} + export async function restorePassword ( ctx: MeasureContext, db: AccountDB, @@ -2996,8 +3080,10 @@ export type AccountMethods = | 'getInviteInfo' | 'signUpJoin' | 'confirm' + | 'checkHasPassword' | 'changePassword' | 'requestPasswordReset' + | 'requestPasswordSetup' | 'restorePassword' | 'leaveWorkspace' | 'changeUsername' @@ -3072,8 +3158,10 @@ export function getMethods (hasSignUp: boolean = true): Partial Date: Sat, 21 Mar 2026 12:36:32 +0500 Subject: [PATCH 058/128] Two-Factor Authentication (2FA) (#10658) * Two-Factor Authentication (2FA) Signed-off-by: Denis Bykhov * Fix test Signed-off-by: Denis Bykhov * Fix test Signed-off-by: Denis Bykhov * Fix tests Signed-off-by: Denis Bykhov * More test fixes Signed-off-by: Denis Bykhov * Fix tests Signed-off-by: Denis Bykhov --------- Signed-off-by: Denis Bykhov --- common/config/rush/pnpm-lock.yaml | 145 +++++++++++++++++ .../packages/account-client/src/client.ts | 42 +++++ .../core/packages/account-client/src/types.ts | 2 + foundations/core/packages/core/src/classes.ts | 1 + models/setting/src/index.ts | 16 ++ plugins/login-assets/lang/cs.json | 14 +- plugins/login-assets/lang/de.json | 14 +- plugins/login-assets/lang/en.json | 6 +- plugins/login-assets/lang/es.json | 14 +- plugins/login-assets/lang/fr.json | 14 +- plugins/login-assets/lang/it.json | 14 +- plugins/login-assets/lang/ja.json | 14 +- plugins/login-assets/lang/pt-br.json | 14 +- plugins/login-assets/lang/pt.json | 14 +- plugins/login-assets/lang/ru.json | 14 +- plugins/login-assets/lang/tr.json | 14 +- plugins/login-assets/lang/zh.json | 14 +- .../src/components/LoginApp.svelte | 8 +- .../src/components/LoginTfaForm.svelte | 65 ++++++++ plugins/login-resources/src/utils.ts | 44 +++++ plugins/login/src/index.ts | 9 +- plugins/setting-assets/lang/cs.json | 11 +- plugins/setting-assets/lang/de.json | 11 +- plugins/setting-assets/lang/en.json | 11 +- plugins/setting-assets/lang/es.json | 11 +- plugins/setting-assets/lang/fr.json | 11 +- plugins/setting-assets/lang/it.json | 11 +- plugins/setting-assets/lang/ja.json | 11 +- plugins/setting-assets/lang/pt-br.json | 11 +- plugins/setting-assets/lang/pt.json | 11 +- plugins/setting-assets/lang/ru.json | 11 +- plugins/setting-assets/lang/tr.json | 11 +- plugins/setting-assets/lang/zh.json | 11 +- plugins/setting-resources/package.json | 6 +- .../src/components/TwoFactorSettings.svelte | 152 ++++++++++++++++++ plugins/setting-resources/src/index.ts | 4 +- plugins/setting/src/index.ts | 15 +- plugins/workbench-resources/src/connect.ts | 2 + server/account/package.json | 1 + .../account/src/__tests__/operations.test.ts | 24 ++- server/account/src/__tests__/postgres.test.ts | 1 + .../src/collections/postgres/migrations.ts | 14 +- .../src/collections/postgres/postgres.ts | 1 + server/account/src/operations.ts | 147 ++++++++++++++++- server/account/src/types.ts | 2 + server/account/src/utils.ts | 13 ++ 46 files changed, 916 insertions(+), 90 deletions(-) create mode 100644 plugins/login-resources/src/components/LoginTfaForm.svelte create mode 100644 plugins/setting-resources/src/components/TwoFactorSettings.svelte diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 8122e73cac..b5ae38fa90 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -26436,6 +26436,9 @@ importers: '@hcengineering/workbench-resources': specifier: workspace:^0.7.0 version: link:../workbench-resources + qrcode: + specifier: ^1.5.4 + version: 1.5.4 svelte: specifier: ^4.2.20 version: 4.2.20 @@ -26446,6 +26449,9 @@ importers: '@types/jest': specifier: ^29.5.5 version: 29.5.14 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.6 '@typescript-eslint/eslint-plugin': specifier: ^6.21.0 version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -35776,6 +35782,9 @@ importers: otp-generator: specifier: ^4.0.1 version: 4.0.1 + otplib: + specifier: ^12.0.1 + version: 12.0.1 postgres: specifier: ^3.4.7 version: 3.4.7 @@ -43857,6 +43866,24 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@otplib/core@12.0.1': + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + + '@otplib/plugin-crypto@12.0.1': + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths + + '@otplib/plugin-thirty-two@12.0.1': + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths + + '@otplib/preset-default@12.0.1': + resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==} + deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths + + '@otplib/preset-v11@12.0.1': + resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -44971,6 +44998,9 @@ packages: '@types/pug@2.0.10': resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -45973,6 +46003,9 @@ packages: cliui@4.1.0: resolution: {integrity: sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -46700,6 +46733,9 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -49602,6 +49638,9 @@ packages: resolution: {integrity: sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==} engines: {node: '>=14.10.0'} + otplib@12.0.1: + resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -49917,6 +49956,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -50214,6 +50257,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.11.2: resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} engines: {node: '>=0.6'} @@ -50355,6 +50403,9 @@ packages: require-main-filename@1.0.1: resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -51172,6 +51223,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thirty-two@1.0.2: + resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} + engines: {node: '>=0.2.6'} + through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} @@ -51819,6 +51874,10 @@ packages: resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -51948,6 +52007,10 @@ packages: yargs-parser@11.1.1: resolution: {integrity: sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -51955,6 +52018,10 @@ packages: yargs@12.0.5: resolution: {integrity: sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -54795,6 +54862,29 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@otplib/core@12.0.1': {} + + '@otplib/plugin-crypto@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + + '@otplib/plugin-thirty-two@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + + '@otplib/preset-default@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + + '@otplib/preset-v11@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -56120,6 +56210,10 @@ snapshots: '@types/pug@2.0.10': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.0 + '@types/qs@6.9.18': {} '@types/querystringify@2.0.2': {} @@ -57322,6 +57416,12 @@ snapshots: strip-ansi: 4.0.0 wrap-ansi: 2.1.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -58062,6 +58162,8 @@ snapshots: diff@7.0.0: {} + dijkstrajs@1.0.3: {} + dingbat-to-unicode@1.0.1: {} dir-compare@4.2.0: @@ -61660,6 +61762,12 @@ snapshots: otp-generator@4.0.1: {} + otplib@12.0.1: + dependencies: + '@otplib/core': 12.0.1 + '@otplib/preset-default': 12.0.1 + '@otplib/preset-v11': 12.0.1 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -61960,6 +62068,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -62319,6 +62429,12 @@ snapshots: pure-rand@6.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.11.2: dependencies: side-channel: 1.1.0 @@ -62478,6 +62594,8 @@ snapshots: require-main-filename@1.0.1: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resedit@1.7.2: @@ -63505,6 +63623,8 @@ snapshots: dependencies: any-promise: 1.3.0 + thirty-two@1.0.2: {} + through2@4.0.2: dependencies: readable-stream: 3.6.2 @@ -64357,6 +64477,12 @@ snapshots: string-width: 1.0.2 strip-ansi: 3.0.1 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -64463,6 +64589,11 @@ snapshots: camelcase: 5.3.1 decamelize: 1.2.0 + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} yargs@12.0.5: @@ -64480,6 +64611,20 @@ snapshots: y18n: 4.0.3 yargs-parser: 11.1.1 + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index f58806f170..1a8e6ea430 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -254,8 +254,14 @@ export interface AccountClient { getWorkspacePermissions: (params: { accountId: AccountUuid, permission: string }) => Promise getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise + verify2fa: (code: string) => Promise + setCookie: () => Promise deleteCookie: () => Promise + + generate2faSecret: () => Promise<{ secret: string, url: string }> + enable2fa: (secret: string, code: string) => Promise + disable2fa: (code: string) => Promise } /** @public */ @@ -1335,6 +1341,42 @@ class AccountClientImpl implements AccountClient { params }) } + + async verify2fa (code: string): Promise { + const request = { + method: 'verify2fa' as const, + params: { code } + } + + return await this.rpc(request) + } + + async generate2faSecret (): Promise<{ secret: string, url: string }> { + const request = { + method: 'generate2faSecret' as const, + params: {} + } + + return await this.rpc(request) + } + + async enable2fa (secret: string, code: string): Promise { + const request = { + method: 'enable2fa' as const, + params: { secret, code } + } + + await this.rpc(request) + } + + async disable2fa (code: string): Promise { + const request = { + method: 'disable2fa' as const, + params: { code } + } + + await this.rpc(request) + } } function withRetry Promise> ( diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index f055a74d70..c9f791f3c2 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -19,6 +19,8 @@ export interface LoginInfo { name?: string socialId?: PersonId token?: string + tfaRequired?: boolean + extra?: Record } export interface EndpointInfo { diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index 50664bfabf..2551c951cd 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -960,6 +960,7 @@ export interface SocialId { export interface AccountInfo { timezone?: string locale?: string + tfaEnabled?: boolean } export type SocialKey = Pick diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 3f6e1a7d86..09a7404caf 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -209,6 +209,22 @@ export function createModel (builder: Builder): void { }, setting.ids.Password ) + + builder.createDoc( + setting.class.SettingsCategory, + core.space.Model, + { + name: 'security', + label: setting.string.Security, + icon: setting.icon.Password, + component: setting.component.TwoFactorSettings, + group: 'settings-account', + role: AccountRole.Guest, + order: 1200 + }, + setting.ids.Security + ) + builder.createDoc( setting.class.SettingsCategory, core.space.Model, diff --git a/plugins/login-assets/lang/cs.json b/plugins/login-assets/lang/cs.json index ba74a3ec3f..c9279bbc50 100644 --- a/plugins/login-assets/lang/cs.json +++ b/plugins/login-assets/lang/cs.json @@ -93,10 +93,14 @@ "SignUpAndJoin": "Registrovat se a připojit", "CreateNewAccount": "Vytvořit nový účet", "SignedInAs": "Přihlášen jako {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Dvoufaktorové ověření", + "EnterTwoFactorCode": "Zadejte dvoufaktorový ověřovací kód", + "TwoFactorCode": "Dvoufaktorový ověřovací kód", + "Verify": "Ověřit", + "SetPassword": "Nastavit heslo", + "SSOPasswordDescription": "Váš účet používá externí přihlášení. Nastavte si heslo prostřednictvím e-mailové potvrzení.", + "SendSetupLink": "Odeslat odkaz na nastavení", + "SSOPasswordEmailSent": "Zkontrolujte svůj e-mail, zda neobsahuje odkaz pro nastavení hesla.", + "SSONoEmailLinked": "K vašemu účtu není přidružena žádná e-mailová adresa. Nejprve ji přidejte v Nastavení účtu → Spravovat identity." } } diff --git a/plugins/login-assets/lang/de.json b/plugins/login-assets/lang/de.json index 721b9e788c..76bc7d3561 100644 --- a/plugins/login-assets/lang/de.json +++ b/plugins/login-assets/lang/de.json @@ -93,10 +93,14 @@ "SignUpAndJoin": "Registrieren & beitreten", "CreateNewAccount": "Neues Konto erstellen", "SignedInAs": "Angemeldet als {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Zweistufige Authentifizierung", + "EnterTwoFactorCode": "Geben Sie den zweistufigen Authentifizierungscode ein", + "TwoFactorCode": "Zweistufiger Authentifizierungscode", + "Verify": "Überprüfen", + "SetPassword": "Passwort festlegen", + "SSOPasswordDescription": "Ihr Konto verwendet externe Anmeldung. Richten Sie ein Passwort über die E-Mail-Bestätigung ein.", + "SendSetupLink": "Setup-Link senden", + "SSOPasswordEmailSent": "Überprüfen Sie Ihre E-Mail auf einen Link zum Festlegen des Passworts.", + "SSONoEmailLinked": "Keine E-Mail-Adresse mit Ihrem Konto verknüpft. Fügen Sie eine unter Kontoeinstellungen → Identitäten verwalten hinzu." } } diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index 491e9f4a01..c16dd1c152 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -96,6 +96,10 @@ "LogInAndJoin": "Log In & Join", "SignUpAndJoin": "Sign Up & Join", "CreateNewAccount": "Create new account", - "SignedInAs": "Signed in as {name}" + "SignedInAs": "Signed in as {name}", + "TwoFactorAuth": "Two-factor authentication", + "EnterTwoFactorCode": "Enter two-factor authentication code", + "TwoFactorCode": "Two-factor authentication code", + "Verify": "Verify" } } diff --git a/plugins/login-assets/lang/es.json b/plugins/login-assets/lang/es.json index 209388928e..b337da57b9 100644 --- a/plugins/login-assets/lang/es.json +++ b/plugins/login-assets/lang/es.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "Registrarse y unirse", "CreateNewAccount": "Crear nueva cuenta", "SignedInAs": "Conectado como {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Autenticación de dos factores", + "EnterTwoFactorCode": "Introduce el código de autenticación de dos factores", + "TwoFactorCode": "Código de autenticación de dos factores", + "Verify": "Verificar", + "SetPassword": "Establecer una contraseña", + "SSOPasswordDescription": "Tu cuenta utiliza inicio de sesión externo. Configura una contraseña mediante confirmación por correo electrónico.", + "SendSetupLink": "Enviar enlace de configuración", + "SSOPasswordEmailSent": "Revisa tu correo electrónico para encontrar un enlace para configurar la contraseña.", + "SSONoEmailLinked": "No hay dirección de correo electrónico vinculada a tu cuenta. Añade una en Configuración de la cuenta → Gestionar identidades primero." } } diff --git a/plugins/login-assets/lang/fr.json b/plugins/login-assets/lang/fr.json index ff8906688f..24eedf83a8 100644 --- a/plugins/login-assets/lang/fr.json +++ b/plugins/login-assets/lang/fr.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "S'inscrire et rejoindre", "CreateNewAccount": "Créer un nouveau compte", "SignedInAs": "Connecté en tant que {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Authentification à deux facteurs", + "EnterTwoFactorCode": "Entrez le code d'authentification à deux facteurs", + "TwoFactorCode": "Code d'authentification à deux facteurs", + "Verify": "Vérifier", + "SetPassword": "Définir un mot de passe", + "SSOPasswordDescription": "Votre compte utilise une connexion externe. Définissez un mot de passe via confirmation par e-mail.", + "SendSetupLink": "Envoyer le lien de configuration", + "SSOPasswordEmailSent": "Vérifiez votre e-mail pour trouver un lien de configuration de mot de passe.", + "SSONoEmailLinked": "Aucune adresse e-mail n'est liée à votre compte. Ajoutez-en une dans Paramètres du compte → Gérer les identités d'abord." } } diff --git a/plugins/login-assets/lang/it.json b/plugins/login-assets/lang/it.json index ca58d59af6..4d9468bf4a 100644 --- a/plugins/login-assets/lang/it.json +++ b/plugins/login-assets/lang/it.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "Iscriviti e partecipa", "CreateNewAccount": "Crea nuovo account", "SignedInAs": "Accesso effettuato come {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Autenticazione a due fattori", + "EnterTwoFactorCode": "Inserisci il codice di autenticazione a due fattori", + "TwoFactorCode": "Codice di autenticazione a due fattori", + "Verify": "Verifica", + "SetPassword": "Imposta una password", + "SSOPasswordDescription": "Il tuo account utilizza l'accesso esterno. Imposta una password tramite conferma via email.", + "SendSetupLink": "Invia link di configurazione", + "SSOPasswordEmailSent": "Controlla la tua email per trovare un link per impostare la password.", + "SSONoEmailLinked": "Nessun indirizzo email è collegato al tuo account. Aggiungine uno in Impostazioni account → Gestisci identità prima." } } diff --git a/plugins/login-assets/lang/ja.json b/plugins/login-assets/lang/ja.json index 74c34d5708..06538a5148 100644 --- a/plugins/login-assets/lang/ja.json +++ b/plugins/login-assets/lang/ja.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "サインアップして参加", "CreateNewAccount": "新規アカウントを作成", "SignedInAs": "{name} としてサインイン中", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "2段階認証", + "EnterTwoFactorCode": "2段階認証コードを入力", + "TwoFactorCode": "2段階認証コード", + "Verify": "確認", + "SetPassword": "パスワードを設定", + "SSOPasswordDescription": "アカウントは外部ログインを使用しています。メール確認でパスワードを設定してください。", + "SendSetupLink": "セットアップリンクを送信", + "SSOPasswordEmailSent": "パスワードを設定するためのリンクが記載されたメールを確認してください。", + "SSONoEmailLinked": "アカウントにメールアドレスがリンクされていません。まず、アカウント設定 → IDの管理 で追加してください。" } } diff --git a/plugins/login-assets/lang/pt-br.json b/plugins/login-assets/lang/pt-br.json index 51930218be..03a2be87bf 100644 --- a/plugins/login-assets/lang/pt-br.json +++ b/plugins/login-assets/lang/pt-br.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "Cadastrar e participar", "CreateNewAccount": "Criar nova conta", "SignedInAs": "Conectado como {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Autenticação de dois fatores", + "EnterTwoFactorCode": "Digite o código de autenticação de dois fatores", + "TwoFactorCode": "Código de autenticação de dois fatores", + "Verify": "Verificar", + "SetPassword": "Definir senha", + "SSOPasswordDescription": "Sua conta usa login externo. Defina uma senha através da confirmação por e-mail.", + "SendSetupLink": "Enviar link de configuração", + "SSOPasswordEmailSent": "Verifique seu e-mail para encontrar o link de configuração da senha.", + "SSONoEmailLinked": "Nenhum endereço de e-mail está vinculado à sua conta. Adicione um em Configurações da Conta → Gerenciar Identidades primeiro." } } diff --git a/plugins/login-assets/lang/pt.json b/plugins/login-assets/lang/pt.json index cc031cf2c6..14e30dbdc1 100644 --- a/plugins/login-assets/lang/pt.json +++ b/plugins/login-assets/lang/pt.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "Registar e participar", "CreateNewAccount": "Criar nova conta", "SignedInAs": "Sessão iniciada como {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Autenticação de dois fatores", + "EnterTwoFactorCode": "Digite o código de autenticação de dois fatores", + "TwoFactorCode": "Código de autenticação de dois fatores", + "Verify": "Verificar", + "SetPassword": "Definir senha", + "SSOPasswordDescription": "Sua conta usa login externo. Defina uma senha através da confirmação por e-mail.", + "SendSetupLink": "Enviar link de configuração", + "SSOPasswordEmailSent": "Verifique seu e-mail para encontrar o link de configuração da senha.", + "SSONoEmailLinked": "Nenhum endereço de e-mail está vinculado à sua conta. Adicione um em Configurações da Conta → Gerenciar Identidades primeiro." } } diff --git a/plugins/login-assets/lang/ru.json b/plugins/login-assets/lang/ru.json index fb4335b7af..eaf7bcaf38 100644 --- a/plugins/login-assets/lang/ru.json +++ b/plugins/login-assets/lang/ru.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "Зарегистрироваться и присоединиться", "CreateNewAccount": "Создать новый аккаунт", "SignedInAs": "Вы вошли как {name}", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "Двухфакторная аутентификация", + "EnterTwoFactorCode": "Введите код двухфакторной аутентификации", + "TwoFactorCode": "Код двухфакторной аутентификации", + "Verify": "Проверить", + "SetPassword": "Установить пароль", + "SSOPasswordDescription": "Ваша учетная запись использует внешний вход. Установите пароль через подтверждение по электронной почте.", + "SendSetupLink": "Отправить ссылку для настройки", + "SSOPasswordEmailSent": "Проверьте свою электронную почту, чтобы найти ссылку для установки пароля.", + "SSONoEmailLinked": "К вашей учетной записи не привязан адрес электронной почты. Сначала добавьте его в Настройки аккаунта → Управление идентификаторами." } } diff --git a/plugins/login-assets/lang/tr.json b/plugins/login-assets/lang/tr.json index 827683b752..5a5e3b25f1 100644 --- a/plugins/login-assets/lang/tr.json +++ b/plugins/login-assets/lang/tr.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "Kayıt ol ve katıl", "CreateNewAccount": "Yeni hesap oluştur", "SignedInAs": "{name} olarak giriş yapıldı", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "İki faktörlü kimlik doğrulama", + "EnterTwoFactorCode": "İki faktörlü kimlik doğrulama kodunu girin", + "TwoFactorCode": "İki faktörlü kimlik doğrulama kodu", + "Verify": "Doğrula", + "SetPassword": "Şifreyi ayarla", + "SSOPasswordDescription": "Hesabınız harici girişi kullanıyor. E-posta onayı ile bir şifre belirleyin.", + "SendSetupLink": "Kurulum bağlantısı gönder", + "SSOPasswordEmailSent": "Şifrenizi belirlemek için bir bağlantı içeren e-postayı kontrol edin.", + "SSONoEmailLinked": "Hesabınıza bağlı bir e-posta adresi yok. Önce Hesap Ayarları → Kimlikleri Yönet bölümünden bir tane ekleyin." } } diff --git a/plugins/login-assets/lang/zh.json b/plugins/login-assets/lang/zh.json index dfd0becf22..8594899508 100644 --- a/plugins/login-assets/lang/zh.json +++ b/plugins/login-assets/lang/zh.json @@ -92,10 +92,14 @@ "SignUpAndJoin": "注册并加入", "CreateNewAccount": "创建新账户", "SignedInAs": "已以 {name} 身份登录", - "SetPassword": "Set a password", - "SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.", - "SendSetupLink": "Send setup link", - "SSOPasswordEmailSent": "Check your email for a link to set your password.", - "SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first." + "TwoFactorAuth": "两步验证", + "EnterTwoFactorCode": "输入两步验证码", + "TwoFactorCode": "两步验证码", + "Verify": "验证", + "SetPassword": "设置密码", + "SSOPasswordDescription": "您的账户使用外部登录。请通过电子邮件确认设置密码。", + "SendSetupLink": "发送设置链接", + "SSOPasswordEmailSent": "请检查您的电子邮件,获取设置密码的链接。", + "SSONoEmailLinked": "您的账户没有关联的电子邮件地址。请先在账户设置 → 管理身份中添加。" } } diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index 9852a5af73..2af6f572bd 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -41,6 +41,7 @@ import PasswordRestore from './PasswordRestore.svelte' import SelectWorkspace from './SelectWorkspace.svelte' import SignupForm from './SignupForm.svelte' + import LoginTfaForm from './LoginTfaForm.svelte' import LoginIcon from './icons/LoginIcon.svelte' import { Pages, getAccount, pages } from '..' import login from '../plugin' @@ -60,6 +61,7 @@ const localLoginHidden = getMetadata(login.metadata.HideLocalLogin) ?? false const useOTP = getMetadata(presentation.metadata.MailUrl) != null && getMetadata(presentation.metadata.MailUrl) !== '' let navigateUrl: string | undefined + let tfaToken: string | undefined = undefined onDestroy(location.subscribe(updatePageLoc)) @@ -79,7 +81,8 @@ 'autoJoin', 'confirm', 'confirmationSend', - 'auth' + 'auth', + 'tfa' ] if (token === undefined ? !allowedUnauthPages.includes(page) : !pages.includes(page)) { const account = fetchMetadataLocalStorage(login.metadata.LastAccount) @@ -87,6 +90,7 @@ } navigateUrl = loc.query?.navigateUrl ?? undefined + tfaToken = loc.query?.token ?? undefined } async function chooseToken (): Promise { @@ -180,6 +184,8 @@ {:else if page === 'changePassword'} + {:else if page === 'tfa'} + (page = 'login')} /> {/if} diff --git a/plugins/login-resources/src/components/LoginTfaForm.svelte b/plugins/login-resources/src/components/LoginTfaForm.svelte new file mode 100644 index 0000000000..02b7eeb3f1 --- /dev/null +++ b/plugins/login-resources/src/components/LoginTfaForm.svelte @@ -0,0 +1,65 @@ + + + +
diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index 3c6c32c658..57abff5b84 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -1025,12 +1025,56 @@ export async function doValidateOtp ( } } +export async function verify2fa (code: string, token: string | undefined): Promise<[Status, LoginInfo | null]> { + if (token === undefined) { + return [new Status(Severity.ERROR, platform.status.Unauthorized, {}), null] + } + + try { + const loginInfo = await getAccountClient(token).verify2fa(code) + + Analytics.handleEvent('verify2fa', { ok: true }) + + return [OK, loginInfo] + } catch (err: any) { + Analytics.handleEvent('verify2fa', { ok: false }) + if (err instanceof PlatformError) { + await handleStatusError('Verify 2fa error', err.status) + + return [err.status, null] + } else { + console.error('Verify 2fa error', err) + Analytics.handleError(err) + + return [unknownError(err), null] + } + } +} + export async function doLoginNavigate ( result: LoginInfo | null, updateStatus: (status: Status) => void, navigateUrl?: string ): Promise { if (result != null) { + if (result.tfaRequired === true) { + const currentLoc = getCurrentLocation() + const loc = getCurrentLocation() + loc.path[1] = 'tfa' + loc.path.length = 2 + if (navigateUrl !== undefined || result.token != null) { + loc.query = { ...loc.query, navigateUrl: navigateUrl ?? null, token: result.token ?? null } + } + + if (loc.path.length === currentLoc.path.length && isSameSegments(currentLoc, loc, loc.path.length)) { + window.location.reload() + return + } + + navigate(loc) + return + } + if (result.token != null) { await logIn(result) } diff --git a/plugins/login/src/index.ts b/plugins/login/src/index.ts index f4b8331f8b..4182d93ed9 100644 --- a/plugins/login/src/index.ts +++ b/plugins/login/src/index.ts @@ -40,7 +40,8 @@ export const pages = [ 'confirmationSend', 'auth', 'login-password', - 'changePassword' + 'changePassword', + 'tfa' ] as const export type Pages = (typeof pages)[number] @@ -97,7 +98,11 @@ export default plugin(loginId, { PasswordExpiredDesc: '' as IntlString, Email: '' as IntlString, Password: '' as IntlString, - PasswordRepeat: '' as IntlString + PasswordRepeat: '' as IntlString, + TwoFactorAuth: '' as IntlString, + EnterTwoFactorCode: '' as IntlString, + TwoFactorCode: '' as IntlString, + Verify: '' as IntlString }, function: { SendInvite: '' as Resource<(email: string, role: AccountRole) => Promise>, diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index 3be383035a..5f86305ff5 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -217,6 +217,15 @@ "SelectUsers": "Vybrat uživatele", "ShowInTitle": "Zobrazit v názvu", "SpaceMembersOnly": "Pouze členové prostoru", - "Reset": "Resetovat" + "Reset": "Resetovat", + "Security": "Zabezpečení", + "TwoFactorAuth": "Dvoufaktorové ověřování", + "TwoFactorAuthDescription": "Dvoufaktorové ověřování přidává další vrstvu zabezpečení k vašemu účtu", + "EnableTwoFactorAuth": "Povolit dvoufaktorové ověřování", + "DisableTwoFactorAuth": "Zakázat dvoufaktorové ověřování", + "TwoFactorAuthEnabled": "Dvoufaktorové ověřování je povoleno", + "TwoFactorAuthDisabled": "Dvoufaktorové ověřování je zakázáno", + "ShowQRCode": "Zobrazit QR kód", + "EnterVerificationCode": "Zadejte ověřovací kód" } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index 1f7940a705..0a7e00ceb9 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -219,6 +219,15 @@ "SelectUsers": "Benutzer auswählen", "ShowInTitle": "Im Titel anzeigen", "SpaceMembersOnly": "Nur Bereichsmitglieder", - "Reset": "Zurücksetzen" + "Reset": "Zurücksetzen", + "Security": "Sicherheit", + "TwoFactorAuth": "Zweistufige Authentifizierung", + "TwoFactorAuthDescription": "Zweistufige Authentifizierung fügt eine zusätzliche Sicherheitsebene zu Ihrem Konto hinzu", + "EnableTwoFactorAuth": "Zweistufige Authentifizierung aktivieren", + "DisableTwoFactorAuth": "Zweistufige Authentifizierung deaktivieren", + "TwoFactorAuthEnabled": "Zweistufige Authentifizierung ist aktiviert", + "TwoFactorAuthDisabled": "Zweistufige Authentifizierung ist deaktiviert", + "ShowQRCode": "QR-Code anzeigen", + "EnterVerificationCode": "Verifizierungscode eingeben" } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 0847e55f7a..f235562cbd 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -219,6 +219,15 @@ "ImportDocumentDescription": "Grants users ability to import documents into the workspace", "SelectUsers": "Select users", "ShowInTitle": "Show in title", - "SpaceMembersOnly": "Space members only" + "SpaceMembersOnly": "Space members only", + "Security": "Security", + "TwoFactorAuth": "Two-factor authentication", + "TwoFactorAuthDescription": "Two-factor authentication adds an extra layer of security to your account", + "EnableTwoFactorAuth": "Enable two-factor authentication", + "DisableTwoFactorAuth": "Disable two-factor authentication", + "TwoFactorAuthEnabled": "Two-factor authentication is enabled", + "TwoFactorAuthDisabled": "Two-factor authentication is disabled", + "ShowQRCode": "Show QR code", + "EnterVerificationCode": "Enter verification code" } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 8afc019dd6..d2588bb0cc 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -210,6 +210,15 @@ "SelectUsers": "Seleccionar usuarios", "ShowInTitle": "Mostrar en el título", "SpaceMembersOnly": "Solo miembros del espacio", - "Reset": "Reiniciar" + "Reset": "Reiniciar", + "Security": "Seguridad", + "TwoFactorAuth": "Autenticación de dos factores", + "TwoFactorAuthDescription": "La autenticación de dos factores añade una capa adicional de seguridad a tu cuenta", + "EnableTwoFactorAuth": "Habilitar autenticación de dos factores", + "DisableTwoFactorAuth": "Deshabilitar autenticación de dos factores", + "TwoFactorAuthEnabled": "La autenticación de dos factores está habilitada", + "TwoFactorAuthDisabled": "La autenticación de dos factores está deshabilitada", + "ShowQRCode": "Mostrar código QR", + "EnterVerificationCode": "Introducir código de verificación" } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 1a5f500790..544d315a63 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -219,6 +219,15 @@ "SelectUsers": "Sélectionner des utilisateurs", "ShowInTitle": "Afficher dans le titre", "SpaceMembersOnly": "Membres de l'espace uniquement", - "Reset": "Réinitialiser" + "Reset": "Réinitialiser", + "Security": "Sécurité", + "TwoFactorAuth": "Authentification à deux facteurs", + "TwoFactorAuthDescription": "L'authentification à deux facteurs ajoute une couche de sécurité supplémentaire à votre compte", + "EnableTwoFactorAuth": "Activer l'authentification à deux facteurs", + "DisableTwoFactorAuth": "Désactiver l'authentification à deux facteurs", + "TwoFactorAuthEnabled": "L'authentification à deux facteurs est activée", + "TwoFactorAuthDisabled": "L'authentification à deux facteurs est désactivée", + "ShowQRCode": "Afficher le code QR", + "EnterVerificationCode": "Entrer le code de vérification" } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index c84180bad6..a24b761625 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -219,6 +219,15 @@ "SelectUsers": "Seleziona utenti", "ShowInTitle": "Mostra nel titolo", "SpaceMembersOnly": "Solo membri dello spazio", - "Reset": "Reset" + "Reset": "Reset", + "Security": "Sicurezza", + "TwoFactorAuth": "Autenticazione a due fattori", + "TwoFactorAuthDescription": "L'autenticazione a due fattori aggiunge un ulteriore livello di sicurezza al tuo account", + "EnableTwoFactorAuth": "Abilita autenticazione a due fattori", + "DisableTwoFactorAuth": "Disabilita autenticazione a due fattori", + "TwoFactorAuthEnabled": "L'autenticazione a due fattori è abilitata", + "TwoFactorAuthDisabled": "L'autenticazione a due fattori è disabilitata", + "ShowQRCode": "Mostra codice QR", + "EnterVerificationCode": "Inserisci codice di verifica" } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index e93be6da77..bd4c6c83af 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -219,6 +219,15 @@ "SelectUsers": "ユーザーを選択", "ShowInTitle": "タイトルに表示", "SpaceMembersOnly": "スペースメンバーのみ", - "Reset": "リセット" + "Reset": "リセット", + "Security": "セキュリティ", + "TwoFactorAuth": "二要素認証", + "TwoFactorAuthDescription": "二要素認証はアカウントにセキュリティの追加レイヤーを追加します", + "EnableTwoFactorAuth": "二要素認証を有効にする", + "DisableTwoFactorAuth": "二要素認証を無効にする", + "TwoFactorAuthEnabled": "二要素認証は有効です", + "TwoFactorAuthDisabled": "二要素認証は無効です", + "ShowQRCode": "QRコードを表示", + "EnterVerificationCode": "確認コードを入力" } } diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 5b3a235f81..05b1bc7ada 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -210,6 +210,15 @@ "SelectUsers": "Selecionar usuários", "ShowInTitle": "Mostrar no título", "SpaceMembersOnly": "Apenas membros do espaço", - "Reset": "Reiniciar" + "Reset": "Reiniciar", + "Security": "Segurança", + "TwoFactorAuth": "Autenticação de dois fatores", + "TwoFactorAuthDescription": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta", + "EnableTwoFactorAuth": "Ativar autenticação de dois fatores", + "DisableTwoFactorAuth": "Desativar autenticação de dois fatores", + "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", + "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", + "ShowQRCode": "Mostrar código QR", + "EnterVerificationCode": "Inserir código de verificação" } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 98426776ea..fb80fd05d8 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -210,6 +210,15 @@ "SelectUsers": "Selecionar usuários", "ShowInTitle": "Mostrar no título", "SpaceMembersOnly": "Apenas membros do espaço", - "Reset": "Reiniciar" + "Reset": "Reiniciar", + "Security": "Segurança", + "TwoFactorAuth": "Autenticação de dois fatores", + "TwoFactorAuthDescription": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta", + "EnableTwoFactorAuth": "Ativar autenticação de dois fatores", + "DisableTwoFactorAuth": "Desativar autenticação de dois fatores", + "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", + "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", + "ShowQRCode": "Mostrar código QR", + "EnterVerificationCode": "Inserir código de verificação" } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index cc0abb4926..1af522701c 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -219,6 +219,15 @@ "ImportDocumentDescription": "Предоставляет пользователям возможность импортировать документы в рабочее пространство", "SelectUsers": "Выбрать пользователей", "ShowInTitle": "Показывать в заголовке", - "SpaceMembersOnly": "Только участники пространства" + "SpaceMembersOnly": "Только участники пространства", + "Security": "Безопасность", + "TwoFactorAuth": "Двухфакторная аутентификация", + "TwoFactorAuthDescription": "Двухфакторная аутентификация добавляет дополнительный уровень безопасности к вашей учетной записи", + "EnableTwoFactorAuth": "Включить двухфакторную аутентификацию", + "DisableTwoFactorAuth": "Отключить двухфакторную аутентификацию", + "TwoFactorAuthEnabled": "Двухфакторная аутентификация включена", + "TwoFactorAuthDisabled": "Двухфакторная аутентификация отключена", + "ShowQRCode": "Показать QR-код", + "EnterVerificationCode": "Введите код подтверждения" } } diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 7da019578a..9b92fe4f11 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -219,6 +219,15 @@ "SelectUsers": "Kullanıcıları seç", "ShowInTitle": "Başlıkta göster", "SpaceMembersOnly": "Yalnızca alan üyeleri", - "Reset": "Sıfırla" + "Reset": "Sıfırla", + "Security": "Güvenlik", + "TwoFactorAuth": "İki faktörlü kimlik doğrulama", + "TwoFactorAuthDescription": "İki faktörlü kimlik doğrulama hesabınıza ek bir güvenlik katmanı ekler", + "EnableTwoFactorAuth": "İki faktörlü kimlik doğrulamayı etkinleştir", + "DisableTwoFactorAuth": "İki faktörlü kimlik doğrulamayı devre dışı bırak", + "TwoFactorAuthEnabled": "İki faktörlü kimlik doğrulama etkin", + "TwoFactorAuthDisabled": "İki faktörlü kimlik doğrulama devre dışı", + "ShowQRCode": "QR kodu göster", + "EnterVerificationCode": "Doğrulama kodunu gir" } } diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index 423257c2c0..1146fc9f78 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -219,6 +219,15 @@ "SelectUsers": "选择用户", "ShowInTitle": "在标题中显示", "SpaceMembersOnly": "仅限空间成员", - "Reset": "重置" + "Reset": "重置", + "Security": "安全", + "TwoFactorAuth": "双因素认证", + "TwoFactorAuthDescription": "双因素认证为您的帐户增加额外的安全层", + "EnableTwoFactorAuth": "启用双因素认证", + "DisableTwoFactorAuth": "禁用双因素认证", + "TwoFactorAuthEnabled": "双因素认证已启用", + "TwoFactorAuthDisabled": "双因素认证已禁用", + "ShowQRCode": "显示QR码", + "EnterVerificationCode": "输入验证码" } } diff --git a/plugins/setting-resources/package.json b/plugins/setting-resources/package.json index 5fb70c7335..909ab1e157 100644 --- a/plugins/setting-resources/package.json +++ b/plugins/setting-resources/package.json @@ -37,7 +37,8 @@ "jest": "^29.7.0", "ts-jest": "^29.1.1", "@types/jest": "^29.5.5", - "svelte-eslint-parser": "^0.33.1" + "svelte-eslint-parser": "^0.33.1", + "@types/qrcode": "^1.5.5" }, "dependencies": { "@hcengineering/platform": "workspace:^0.7.19", @@ -66,6 +67,7 @@ "@hcengineering/chat": "workspace:^0.7.0", "@hcengineering/integration-client": "workspace:^0.7.0", "@hcengineering/rank": "workspace:^0.7.17", - "@hcengineering/rating": "workspace:^0.7.0" + "@hcengineering/rating": "workspace:^0.7.0", + "qrcode": "^1.5.4" } } diff --git a/plugins/setting-resources/src/components/TwoFactorSettings.svelte b/plugins/setting-resources/src/components/TwoFactorSettings.svelte new file mode 100644 index 0000000000..a0678cba18 --- /dev/null +++ b/plugins/setting-resources/src/components/TwoFactorSettings.svelte @@ -0,0 +1,152 @@ + + + +
+
+ +
+ +
+
+
+ + {#if showSetup} +
+ {#if !tfaEnabled} +
+
+ 2FA QR Code +
+
+ {secret} +
+
+ {/if} + +
+
+
+ {/if} +
+
diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 30a09f6a3f..1f3709f4d7 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -30,6 +30,7 @@ import Password from './components/Password.svelte' import Privacy from './components/Privacy.svelte' import Profile from './components/Profile.svelte' import Settings from './components/Settings.svelte' +import TwoFactorSettings from './components/TwoFactorSettings.svelte' import { Analytics } from '@hcengineering/analytics' import ClassAttributes from './components/ClassAttributes.svelte' @@ -167,7 +168,8 @@ export default async (): Promise => ({ AddSocialId, AddEmailSocialId, EmployeeRefEditor, - UserRoleSelect + UserRoleSelect, + TwoFactorSettings }, actionImpl: { DeleteMixin diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index b9836b6995..a22f122c0e 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -199,7 +199,8 @@ export default plugin(settingId, { Export: '' as Ref, OfficeSettings: '' as Ref, DisablePermissionsConfiguration: '' as Ref, - Mailboxes: '' as Ref + Mailboxes: '' as Ref, + Security: '' as Ref }, mixin: { Editable: '' as Ref>, @@ -244,7 +245,8 @@ export default plugin(settingId, { Mailboxes: '' as AnyComponent, AddEmailSocialId: '' as AnyComponent, OfficeSettings: '' as AnyComponent, - UserRoleSelect: '' as AnyComponent + UserRoleSelect: '' as AnyComponent, + TwoFactorSettings: '' as AnyComponent }, string: { Settings: '' as IntlString, @@ -316,6 +318,15 @@ export default plugin(settingId, { MailboxErrorMailboxCountLimit: '' as IntlString, DeleteMailbox: '' as IntlString, MailboxDeleteConfirmation: '' as IntlString, + Security: '' as IntlString, + TwoFactorAuth: '' as IntlString, + TwoFactorAuthDescription: '' as IntlString, + EnableTwoFactorAuth: '' as IntlString, + DisableTwoFactorAuth: '' as IntlString, + TwoFactorAuthEnabled: '' as IntlString, + TwoFactorAuthDisabled: '' as IntlString, + ShowQRCode: '' as IntlString, + EnterVerificationCode: '' as IntlString, IntegrationFailed: '' as IntlString, IntegrationError: '' as IntlString, EmailIsUsed: '' as IntlString, diff --git a/plugins/workbench-resources/src/connect.ts b/plugins/workbench-resources/src/connect.ts index 0b6b13a49b..3c2d38b13a 100644 --- a/plugins/workbench-resources/src/connect.ts +++ b/plugins/workbench-resources/src/connect.ts @@ -127,6 +127,8 @@ export async function connect (title: string): Promise { break } + console.log('workspaceLoginInfo', workspaceLoginInfo) + const token = workspaceLoginInfo.token setMetadata(presentation.metadata.Token, workspaceLoginInfo.token) diff --git a/server/account/package.json b/server/account/package.json index 25a5ffaa0e..7ea94768ef 100644 --- a/server/account/package.json +++ b/server/account/package.json @@ -47,6 +47,7 @@ "@hcengineering/analytics": "workspace:^0.7.17", "@hcengineering/server-storage": "workspace:^0.7.16", "@hcengineering/server-core": "workspace:^0.7.18", + "otplib": "^12.0.1", "@hcengineering/server-pipeline": "workspace:^0.7.0" } } diff --git a/server/account/src/__tests__/operations.test.ts b/server/account/src/__tests__/operations.test.ts index 23957ac14a..959e3470cd 100644 --- a/server/account/src/__tests__/operations.test.ts +++ b/server/account/src/__tests__/operations.test.ts @@ -1400,7 +1400,8 @@ describe('account operations', () => { account: mockAccountId, token: expect.any(String), name: 'John Doe', - socialId: 'social-id' + socialId: 'social-id', + tfaRequired: false }) }) @@ -1439,7 +1440,8 @@ describe('account operations', () => { account: mockAccountId, token: undefined, name: 'John Doe', - socialId: 'social-id' + socialId: 'social-id', + tfaRequired: false }) }) @@ -1984,7 +1986,8 @@ describe('account operations', () => { account: mockPersonId, name: 'John Doe', socialId: mockSocialId._id, - token: expect.any(String) + token: expect.any(String), + tfaRequired: false }) expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id }) @@ -2030,7 +2033,8 @@ describe('account operations', () => { account: mockPersonId, name: 'John Doe', socialId: mockSocialId._id, - token: expect.any(String) + token: expect.any(String), + tfaRequired: false }) expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id }) @@ -2071,7 +2075,8 @@ describe('account operations', () => { account: callerAccountId, name: 'John Doe', socialId: mockSocialId._id, - token: expect.any(String) + token: undefined, + tfaRequired: false }) expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id }) @@ -2126,7 +2131,8 @@ describe('account operations', () => { account: callerAccountId, name: 'John Doe', socialId: mockSocialId._id, - token: expect.any(String) + token: undefined, + tfaRequired: false }) expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id }) @@ -2173,7 +2179,8 @@ describe('account operations', () => { account: callerAccountId, name: 'John Doe', socialId: mockSocialId._id, - token: expect.any(String) + token: undefined, + tfaRequired: false }) expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id }) @@ -2476,7 +2483,8 @@ describe('account operations', () => { account: mockAccountId, name: 'John Doe', token: expect.any(String), - socialId: mockSocialId._id + socialId: mockSocialId._id, + tfaRequired: false }) expect(utils.setPassword).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, mockAccountId, mockNewPassword) diff --git a/server/account/src/__tests__/postgres.test.ts b/server/account/src/__tests__/postgres.test.ts index 5b9e434c11..863ceb9baa 100644 --- a/server/account/src/__tests__/postgres.test.ts +++ b/server/account/src/__tests__/postgres.test.ts @@ -337,6 +337,7 @@ describe('AccountPostgresDbCollection', () => { a.automatic, a.max_workspaces, a.failed_login_attempts, + a.tfa_secret, p.hash, p.salt FROM global_account.account as a diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index c9bce8442c..adaef2de9c 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -81,7 +81,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][] getV21Migration(ns, flavor), getV22Migration(ns, flavor), getV23Migration(ns, flavor), - getV24Migration(ns, flavor) + getV24Migration(ns, flavor), + getV25Migration(ns, flavor) ] } @@ -782,3 +783,14 @@ function getV24Migration (ns: string, flavor: DBFlavor): [string, string] { ` ] } + +function getV25Migration (ns: string, flavor: DBFlavor): [string, string] { + const types = dbTypes[flavor] + return [ + 'account_db_v25_add_2fa_to_account', + ` + ALTER TABLE ${ns}.account + ADD COLUMN IF NOT EXISTS tfa_secret ${types.string}; + ` + ] +} diff --git a/server/account/src/collections/postgres/postgres.ts b/server/account/src/collections/postgres/postgres.ts index 53bc5f98ba..4ab5aea2b1 100644 --- a/server/account/src/collections/postgres/postgres.ts +++ b/server/account/src/collections/postgres/postgres.ts @@ -453,6 +453,7 @@ export class AccountPostgresDbCollection a.automatic, a.max_workspaces, a.failed_login_attempts, + a.tfa_secret, p.hash, p.salt FROM ${this.getTableName()} as a diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index be19c3a3f5..a1869b41c9 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -123,9 +123,14 @@ import { recordFailedLoginAttempt, resetFailedLoginAttempts, updatePasswordAgingRule, - checkPasswordAging + checkPasswordAging, + generateTotpSecret, + verifyTotpCode, + getTotpUrl } from './utils' +const NIL_UUID = '00000000-0000-0000-0000-000000000000' as AccountUuid + // Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params // to the database layer when searching/inserting as they may contain SQL injection // !!! NEVER PASS "params" DIRECTLY in any DB functions !!! @@ -229,9 +234,16 @@ export async function login ( return { account: existingAccount.uuid, - token: isConfirmed ? generateToken(existingAccount.uuid, undefined, extraToken) : undefined, + token: isConfirmed + ? generateToken( + existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid, + undefined, + existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken + ) + : undefined, name: getPersonName(person), - socialId: emailSocialId._id + socialId: emailSocialId._id, + tfaRequired: isConfirmed && existingAccount.tfaSecret != null } } catch (err: any) { Analytics.handleError(err) @@ -508,15 +520,26 @@ export async function validateOtp ( await resetFailedLoginAttempts(db, emailSocialId.personUuid as AccountUuid) + const isConfirmed = emailSocialId.verifiedOn != null || action !== 'verify' + const extraToken: Record = isAdminEmail(normalizedEmail) ? { admin: 'true', authMethod: 'otp' } : { authMethod: 'otp' } + const _token = isConfirmed + ? generateToken( + targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid, + undefined, + targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken + ) + : undefined + return { account: emailSocialId.personUuid as AccountUuid, name: getPersonName(person), socialId: emailSocialId._id, - token: generateToken(emailSocialId.personUuid, undefined, extraToken) + token: _token, + tfaRequired: targetAccount?.tfaSecret != null } } catch (err: any) { Analytics.handleError(err) @@ -1694,6 +1717,112 @@ export async function deleteWorkspace ( ) } +export async function generate2faSecret ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string +): Promise<{ secret: string, url: string }> { + const { account: accountUuid } = decodeTokenVerbose(ctx, token) + const account = await getAccount(db, accountUuid) + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: accountUuid })) + } + + const emailSocialId = await db.socialId.findOne({ personUuid: accountUuid, type: SocialIdType.EMAIL }) + if (emailSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const secret = generateTotpSecret() + const app = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName) ?? 'Huly' + const url = getTotpUrl(emailSocialId.value, app, secret) + + return { secret, url } +} + +export async function enable2fa ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { secret: string, code: string } +): Promise { + const { secret, code } = params + const { account: accountUuid } = decodeTokenVerbose(ctx, token) + + if (!verifyTotpCode(secret, code)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {})) + } + + await db.account.update({ uuid: accountUuid }, { tfaSecret: secret }) + ctx.info('2FA enabled', { accountUuid }) +} + +export async function disable2fa ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { code: string } +): Promise { + const { code } = params + const { account: accountUuid } = decodeTokenVerbose(ctx, token) + + const account = await getAccount(db, accountUuid) + if (account?.tfaSecret == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + if (!verifyTotpCode(account.tfaSecret, code)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {})) + } + + await db.account.update({ uuid: accountUuid }, { tfaSecret: undefined }) + ctx.info('2FA disabled', { accountUuid }) +} + +export async function verify2fa ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { code: string } +): Promise { + const { code } = params + const decoded = decodeTokenVerbose(ctx, token) + const accountUuid = + decoded.account === NIL_UUID || decoded.account == null + ? (decoded.extra?.tfaAccount as AccountUuid) + : decoded.account + const extra = decoded.extra + + const account = await getAccount(db, accountUuid) + if (account?.tfaSecret == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + if (!verifyTotpCode(account.tfaSecret, code)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {})) + } + + const person = await db.person.findOne({ uuid: accountUuid }) + if (person == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {})) + } + + const socialId = await db.socialId.findOne({ personUuid: accountUuid, verifiedOn: { $gt: 0 } }) + + const { tfaAccount, ...filteredExtra } = extra ?? {} + + return { + account: accountUuid, + token: generateToken(accountUuid, undefined, filteredExtra), + name: getPersonName(person), + socialId: socialId?._id + } +} + /* =================================== */ /* ==========READ OPERATIONS========== */ /* =================================== */ @@ -2274,7 +2403,7 @@ export async function getAccountInfo ( if (account === undefined || account === null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) } - return { timezone: account?.timezone, locale: account?.locale } + return { timezone: account?.timezone, locale: account?.locale, tfaEnabled: account?.tfaSecret != null } } export async function ensurePerson ( @@ -3089,6 +3218,10 @@ export type AccountMethods = | 'changeUsername' | 'updateWorkspaceName' | 'deleteWorkspace' + | 'generate2faSecret' + | 'enable2fa' + | 'disable2fa' + | 'verify2fa' | 'getRegionInfo' | 'getUserWorkspaces' | 'getWorkspaceInfo' @@ -3167,6 +3300,10 @@ export function getMethods (hasSignUp: boolean = true): Partial Date: Sun, 22 Mar 2026 03:23:41 +0700 Subject: [PATCH 059/128] Improve error handling in notification service (#10662) Signed-off-by: Artem Savchenko --- common/config/rush/pnpm-lock.yaml | 11 +- .../pod-notification/package.json | 3 + .../src/__tests__/push.test.ts | 158 ++++++++++++++++++ .../src/__tests__/server.test.ts | 129 ++++++++++++++ .../pod-notification/src/index.ts | 39 ++++- .../notification/pod-notification/src/main.ts | 58 +++---- .../notification/pod-notification/src/push.ts | 64 +++++++ .../pod-notification/src/server.ts | 10 +- 8 files changed, 426 insertions(+), 46 deletions(-) create mode 100644 services/notification/pod-notification/src/__tests__/push.test.ts create mode 100644 services/notification/pod-notification/src/__tests__/server.test.ts create mode 100644 services/notification/pod-notification/src/push.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b5ae38fa90..f2566db58f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -40178,6 +40178,12 @@ importers: ../../services/notification/pod-notification: dependencies: + '@hcengineering/analytics': + specifier: workspace:^0.7.17 + version: link:../../../foundations/core/packages/analytics + '@hcengineering/analytics-service': + specifier: workspace:^0.7.17 + version: link:../../../foundations/core/packages/analytics-service '@hcengineering/client': specifier: workspace:^0.7.18 version: link:../../../foundations/core/packages/client @@ -40193,6 +40199,9 @@ importers: '@hcengineering/platform': specifier: workspace:^0.7.19 version: link:../../../foundations/core/packages/platform + '@hcengineering/server-core': + specifier: workspace:^0.7.18 + version: link:../../../foundations/server/packages/core '@hcengineering/server-token': specifier: workspace:^0.7.17 version: link:../../../foundations/core/packages/token @@ -61548,7 +61557,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) + webpack: 5.102.1 node-localstorage@2.2.1: dependencies: diff --git a/services/notification/pod-notification/package.json b/services/notification/pod-notification/package.json index b7c1f001ed..55827d9637 100644 --- a/services/notification/pod-notification/package.json +++ b/services/notification/pod-notification/package.json @@ -54,11 +54,14 @@ "@types/web-push": "^3.6.4" }, "dependencies": { + "@hcengineering/analytics": "workspace:^0.7.17", + "@hcengineering/analytics-service": "workspace:^0.7.17", "@hcengineering/client": "workspace:^0.7.18", "@hcengineering/client-resources": "workspace:^0.7.18", "@hcengineering/core": "workspace:^0.7.24", "@hcengineering/notification": "workspace:^0.7.0", "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/server-core": "workspace:^0.7.18", "@hcengineering/server-token": "workspace:^0.7.17", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/services/notification/pod-notification/src/__tests__/push.test.ts b/services/notification/pod-notification/src/__tests__/push.test.ts new file mode 100644 index 0000000000..364176cc77 --- /dev/null +++ b/services/notification/pod-notification/src/__tests__/push.test.ts @@ -0,0 +1,158 @@ +// +// 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 { MeasureContext, Ref } from '@hcengineering/core' +import type { PushData, PushSubscription } from '@hcengineering/notification' +import webpush, { WebPushError } from 'web-push' +import { isDisposableSubscriptionError, sendPushToSubscription, webPushErrorBodyString } from '../push' + +jest.mock('web-push', () => { + const actual = jest.requireActual('web-push') + return { + __esModule: true, + WebPushError: actual.WebPushError, + default: { + ...(actual ?? {}), + sendNotification: jest.fn() + } + } +}) + +const sendNotificationMock = webpush.sendNotification as jest.MockedFunction + +function mkWebPushError (body: string, statusCode: number = 410): WebPushError { + return new WebPushError('push failed', statusCode, {}, body, 'https://push.example/ep') +} + +function createMockMeasureContext (): MeasureContext { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn(), + with: jest.fn(), + withSync: jest.fn(), + extractMeta: jest.fn(() => ({})), + contextData: {}, + getParams: jest.fn(() => ({})), + measure: jest.fn(), + end: jest.fn() + } as unknown as MeasureContext +} + +function mkSubscription (id: string): PushSubscription { + const sub: PushSubscription = { + _id: id as Ref, + endpoint: 'https://push.example/ep', + keys: { p256dh: 'p256', auth: 'auth' }, + user: 'user-1' as never, + space: 'space-1' as never, + modifiedOn: 0, + modifiedBy: 'user-1' as never + } as any + return sub +} + +const sampleData: PushData = { title: 't', body: 'b' } + +describe('webPushErrorBodyString', () => { + it('returns string body as-is', () => { + const err = mkWebPushError('subscription expired') + expect(webPushErrorBodyString(err)).toBe('subscription expired') + }) +}) + +describe('isDisposableSubscriptionError', () => { + it.each([ + ['expired', 'subscription expired'], + ['Unregistered', 'push subscription Unregistered'], + ['No such subscription', 'No such subscription'] + ])('matches disposable pattern %s', (_name, body) => { + expect(isDisposableSubscriptionError(mkWebPushError(body))).toBe(true) + }) + + it('returns false for other push errors', () => { + expect(isDisposableSubscriptionError(mkWebPushError('Rate limit exceeded', 429))).toBe(false) + }) +}) + +describe('sendPushToSubscription', () => { + beforeEach(() => { + sendNotificationMock.mockReset() + }) + + it('returns empty when all sends succeed', async () => { + sendNotificationMock.mockResolvedValue({ statusCode: 201, body: '', headers: {} }) + const ctx = createMockMeasureContext() + const subs = [mkSubscription('s1'), mkSubscription('s2')] + const result = await sendPushToSubscription(ctx, subs, sampleData) + expect(result).toEqual([]) + expect(sendNotificationMock).toHaveBeenCalledTimes(2) + expect(ctx.warn).not.toHaveBeenCalled() + expect(ctx.error).not.toHaveBeenCalled() + }) + + it('collects subscription id for disposable WebPushError', async () => { + sendNotificationMock.mockRejectedValueOnce(mkWebPushError('Unregistered')) + const ctx = createMockMeasureContext() + const subs = [mkSubscription('drop-me')] + const result = await sendPushToSubscription(ctx, subs, sampleData) + expect(result).toEqual(['drop-me']) + expect(ctx.warn).not.toHaveBeenCalled() + expect(ctx.error).not.toHaveBeenCalled() + }) + + it('warns but does not collect id for non-disposable WebPushError', async () => { + const wpe = mkWebPushError('Internal error', 500) + sendNotificationMock.mockRejectedValueOnce(wpe) + const ctx = createMockMeasureContext() + const subs = [mkSubscription('keep-me')] + const result = await sendPushToSubscription(ctx, subs, sampleData) + expect(result).toEqual([]) + expect(ctx.warn).toHaveBeenCalledWith('Web push failed for subscription', { + statusCode: 500, + body: 'Internal error', + subscriptionId: 'keep-me' + }) + expect(ctx.error).not.toHaveBeenCalled() + }) + + it('logs unexpected errors', async () => { + const boom = new TypeError('network') + sendNotificationMock.mockRejectedValueOnce(boom) + const ctx = createMockMeasureContext() + const subs = [mkSubscription('sub-x')] + const result = await sendPushToSubscription(ctx, subs, sampleData) + expect(result).toEqual([]) + expect(ctx.error).toHaveBeenCalledWith('Unexpected error sending web push', { + error: boom, + subscriptionId: 'sub-x' + }) + expect(ctx.warn).not.toHaveBeenCalled() + }) + + it('processes subscriptions independently', async () => { + sendNotificationMock + .mockResolvedValueOnce({ statusCode: 201, body: '', headers: {} }) + .mockRejectedValueOnce(mkWebPushError('expired')) + .mockRejectedValueOnce(new Error('weird')) + const ctx = createMockMeasureContext() + const subs = [mkSubscription('a'), mkSubscription('b'), mkSubscription('c')] + const result = await sendPushToSubscription(ctx, subs, sampleData) + expect(result).toEqual(['b']) + expect(ctx.warn).not.toHaveBeenCalled() + expect(ctx.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/services/notification/pod-notification/src/__tests__/server.test.ts b/services/notification/pod-notification/src/__tests__/server.test.ts new file mode 100644 index 0000000000..4e28b04081 --- /dev/null +++ b/services/notification/pod-notification/src/__tests__/server.test.ts @@ -0,0 +1,129 @@ +// +// 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 http from 'http' +import { createServer } from '../server' +import { ApiError } from '../error' +import type { Endpoint } from '../types' + +function httpRequest ( + url: string, + options: { method?: string, body?: string } = {} +): Promise<{ status: number, json: () => Promise }> { + return new Promise((resolve, reject) => { + const u = new URL(url) + const req = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname + u.search, + method: options.method ?? 'GET', + headers: + options.body !== undefined + ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(options.body) } + : undefined + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (c) => { + chunks.push(c) + }) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + resolve({ + status: res.statusCode ?? 0, + json: async () => JSON.parse(text) as unknown + }) + }) + } + ) + req.on('error', reject) + if (options.body !== undefined) { + req.write(options.body) + } + req.end() + }) +} + +function withServer (endpoints: Endpoint[], fn: (baseUrl: string) => Promise): Promise { + const app = createServer(endpoints) + return new Promise((resolve, reject) => { + const srv = app.listen(0, '127.0.0.1', () => { + const addr = srv.address() + const port = typeof addr === 'object' && addr !== null ? addr.port : 0 + const baseUrl = `http://127.0.0.1:${port}` + void fn(baseUrl) + .then(() => { + srv.close((err) => { + err != null ? reject(err) : resolve() + }) + }) + .catch((e) => { + srv.close(() => { + reject(e) + }) + }) + }) + srv.on('error', reject) + }) +} + +describe('createServer', () => { + it('returns 404 for unknown routes', async () => { + await withServer([], async (baseUrl) => { + const res = await httpRequest(`${baseUrl}/missing`) + expect(res.status).toBe(404) + const body = (await res.json()) as { message: string } + expect(body.message).toBe('Not found') + }) + }) + + it('maps ApiError to 400 with code', async () => { + const endpoints: Endpoint[] = [ + { + endpoint: '/err', + type: 'post', + handler: async (_req, _res) => { + throw new ApiError('INVALID', 'bad input') + } + } + ] + await withServer(endpoints, async (baseUrl) => { + const res = await httpRequest(`${baseUrl}/err`, { method: 'POST', body: '{}' }) + expect(res.status).toBe(400) + const body = (await res.json()) as { code: string, message: string } + expect(body.code).toBe('INVALID') + expect(body.message).toBe('bad input') + }) + }) + + it('maps unexpected errors to 500', async () => { + const endpoints: Endpoint[] = [ + { + endpoint: '/boom', + type: 'post', + handler: async (_req, _res) => { + throw new Error('boom') + } + } + ] + await withServer(endpoints, async (baseUrl) => { + const res = await httpRequest(`${baseUrl}/boom`, { method: 'POST', body: '{}' }) + expect(res.status).toBe(500) + const body = (await res.json()) as { message: string } + expect(body.message).toBe('boom') + }) + }) +}) diff --git a/services/notification/pod-notification/src/index.ts b/services/notification/pod-notification/src/index.ts index c225f89a0f..380371a129 100644 --- a/services/notification/pod-notification/src/index.ts +++ b/services/notification/pod-notification/src/index.ts @@ -1,5 +1,5 @@ // -// Copyright © 2023 Hardcore Engineering Inc. +// 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 @@ -13,10 +13,39 @@ // limitations under the License. // +import { Analytics } from '@hcengineering/analytics' +import { SplitLogger, configureAnalytics, createOpenTelemetryMetricsContext } from '@hcengineering/analytics-service' +import { newMetrics } from '@hcengineering/core' +import { initStatisticsContext } from '@hcengineering/server-core' +import { join } from 'path' import { main } from './main' -void main().catch((err) => { - if (err != null) { - console.error(err) - } +configureAnalytics('notification', process.env.VERSION ?? '0.7.0') +const metricsContext = initStatisticsContext('notification', { + factory: () => + createOpenTelemetryMetricsContext( + 'notification', + {}, + {}, + newMetrics(), + new SplitLogger('notification-service', { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) + ) +}) + +Analytics.setTag('application', 'notification-service') + +process.on('uncaughtException', (e) => { + metricsContext.error('UncaughtException', { error: e }) +}) + +process.on('unhandledRejection', (reason, promise) => { + metricsContext.error('Unhandled Rejection at:', { promise, reason }) +}) + +void main(metricsContext).catch((err) => { + metricsContext.error('Failed to start', { error: err }) + process.exit(1) }) diff --git a/services/notification/pod-notification/src/main.ts b/services/notification/pod-notification/src/main.ts index 1d1324909e..7db735c486 100644 --- a/services/notification/pod-notification/src/main.ts +++ b/services/notification/pod-notification/src/main.ts @@ -1,5 +1,5 @@ // -// Copyright © 2023 Hardcore Engineering Inc. +// 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 @@ -13,47 +13,34 @@ // limitations under the License. // -import type { Ref } from '@hcengineering/core' +import type { MeasureContext } from '@hcengineering/core' import { PushSubscription, type PushData } from '@hcengineering/notification' import type { Request, Response } from 'express' -import webpush, { WebPushError } from 'web-push' +import webpush from 'web-push' import config from './config' +import { sendPushToSubscription } from './push' import { createServer, listen } from './server' import { Endpoint } from './types' -const errorMessages = ['expired', 'Unregistered', 'No such subscription'] -async function sendPushToSubscription ( - subscriptions: PushSubscription[], - data: PushData -): Promise[]> { - const result: Ref[] = [] - for (const subscription of subscriptions) { - try { - await webpush.sendNotification(subscription, JSON.stringify(data)) - } catch (err: any) { - if (err instanceof WebPushError) { - if (errorMessages.some((p) => JSON.stringify(err.body).includes(p))) { - result.push(subscription._id) - } - } - } - } - return result -} - -export const main = async (): Promise => { - console.log('Notification service has been started') +export const main = async (ctx: MeasureContext): Promise => { + ctx.info('Notification service starting') let webpushInitDone = false if (config.PushPublicKey !== undefined && config.PushPrivateKey !== undefined) { try { const subj = config.PushSubject ?? 'mailto:hey@huly.io' - console.log('Setting VAPID details', subj, config.PushPublicKey.length, config.PushPrivateKey.length) - webpush.setVapidDetails(config.PushSubject ?? 'mailto:hey@huly.io', config.PushPublicKey, config.PushPrivateKey) + ctx.info('Setting VAPID details', { + subject: subj, + publicKeyLen: config.PushPublicKey.length, + privateKeyLen: config.PushPrivateKey.length + }) + webpush.setVapidDetails(subj, config.PushPublicKey, config.PushPrivateKey) webpushInitDone = true - } catch (err: any) { - console.error(err) + } catch (err: unknown) { + ctx.error('Failed to set VAPID details', { error: err }) } + } else { + ctx.warn('VAPID keys not configured; /web-push will return empty results until keys are set') } const checkAuth = (req: Request, res: Response): boolean => { @@ -92,15 +79,18 @@ export const main = async (): Promise => { return } - const result = await sendPushToSubscription(subscriptions, data) + const result = await sendPushToSubscription(ctx, subscriptions, data) res.json({ result }).end() } } ] - const server = listen(createServer(endpoints), config.Port) + const server = listen(createServer(endpoints), config.Port, undefined, () => { + ctx.info('Notification service listening', { port: config.Port }) + }) const shutdown = (): void => { + ctx.info('Closed') server.close(() => { process.exit() }) @@ -108,10 +98,4 @@ export const main = async (): Promise => { process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) - process.on('uncaughtException', (e) => { - console.error(e) - }) - process.on('unhandledRejection', (e) => { - console.error(e) - }) } diff --git a/services/notification/pod-notification/src/push.ts b/services/notification/pod-notification/src/push.ts new file mode 100644 index 0000000000..610cfcbb5f --- /dev/null +++ b/services/notification/pod-notification/src/push.ts @@ -0,0 +1,64 @@ +// +// 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 { MeasureContext, Ref } from '@hcengineering/core' +import { type PushData, type PushSubscription } from '@hcengineering/notification' +import webpush, { WebPushError } from 'web-push' + +/** Push endpoints return these when the subscription should be removed — not actionable server errors. */ +const disposableSubscriptionPatterns = ['expired', 'Unregistered', 'No such subscription'] + +export function webPushErrorBodyString (err: WebPushError): string { + const b = err.body + if (typeof b === 'string') return b + try { + return JSON.stringify(b) + } catch { + return String(b) + } +} + +export function isDisposableSubscriptionError (err: WebPushError): boolean { + const body = webPushErrorBodyString(err) + return disposableSubscriptionPatterns.some((p) => body.includes(p)) +} + +export async function sendPushToSubscription ( + ctx: MeasureContext, + subscriptions: PushSubscription[], + data: PushData +): Promise[]> { + const result: Ref[] = [] + for (const subscription of subscriptions) { + try { + await webpush.sendNotification(subscription, JSON.stringify(data)) + } catch (err: unknown) { + if (err instanceof WebPushError) { + if (isDisposableSubscriptionError(err)) { + result.push(subscription._id) + } else { + ctx.warn('Web push failed for subscription', { + statusCode: err.statusCode, + body: err.body, + subscriptionId: subscription._id + }) + } + } else { + ctx.error('Unexpected error sending web push', { error: err, subscriptionId: subscription._id }) + } + } + } + return result +} diff --git a/services/notification/pod-notification/src/server.ts b/services/notification/pod-notification/src/server.ts index e07e7ed029..194772a576 100644 --- a/services/notification/pod-notification/src/server.ts +++ b/services/notification/pod-notification/src/server.ts @@ -1,5 +1,5 @@ // -// Copyright © 2023 Hardcore Engineering Inc. +// 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 @@ -59,9 +59,13 @@ export function createServer (endpoints: Endpoint[]): Express { return app } -export function listen (e: Express, port: number, host?: string): Server { +export function listen (e: Express, port: number, host?: string, onListening?: () => void): Server { const cb = (): void => { - console.log(`Notification service has been started at ${host ?? '*'}:${port}`) + if (onListening !== undefined) { + onListening() + } else { + console.log(`Notification service has been started at ${host ?? '*'}:${port}`) + } } return host !== undefined ? e.listen(port, host, cb) : e.listen(port, cb) From c800a95b501076ba2831ea4d6f8364b00fd97e2f Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Sun, 22 Mar 2026 08:30:54 +0500 Subject: [PATCH 060/128] Implement persistent table sorting by integrating with view options. (#10665) Signed-off-by: Denis Bykhov --- .../src/components/RelationshipTable.svelte | 10 ++++++++-- .../src/components/RelationshipTableBrowser.svelte | 11 ++++++++++- plugins/view-resources/src/components/Table.svelte | 11 +++++++++-- .../view-resources/src/components/TableBrowser.svelte | 11 ++++++++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/plugins/view-resources/src/components/RelationshipTable.svelte b/plugins/view-resources/src/components/RelationshipTable.svelte index 189f4e6a84..299f96b2d7 100644 --- a/plugins/view-resources/src/components/RelationshipTable.svelte +++ b/plugins/view-resources/src/components/RelationshipTable.svelte @@ -85,12 +85,17 @@ $: associations = buildConfigAssociation(config) let _sortKey = prefferedSorting + let sortOrder = SortingOrder.Descending let userSorting = false - $: if (!userSorting) { + $: if (!userSorting && !viewOptions?.orderBy) { _sortKey = prefferedSorting } - let sortOrder = SortingOrder.Descending + $: if (viewOptions?.orderBy) { + _sortKey = viewOptions.orderBy[0] + sortOrder = viewOptions.orderBy[1] + } + let loading = 0 let objects: Doc[] = [] @@ -204,6 +209,7 @@ } else { sortOrder = sortOrder === SortingOrder.Ascending ? SortingOrder.Descending : SortingOrder.Ascending } + dispatch('sort', { key: _sortKey, order: sortOrder }) } const joinProps = (attribute: AttributeModel, object: Doc, readonly: boolean) => { diff --git a/plugins/view-resources/src/components/RelationshipTableBrowser.svelte b/plugins/view-resources/src/components/RelationshipTableBrowser.svelte index 80b1f93b07..26ccaaed51 100644 --- a/plugins/view-resources/src/components/RelationshipTableBrowser.svelte +++ b/plugins/view-resources/src/components/RelationshipTableBrowser.svelte @@ -13,13 +13,14 @@ // limitations under the License. --> From f9cb97007e1f4e0e9be5d89ead8f818934ab1bf6 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 22 Mar 2026 12:29:19 +0700 Subject: [PATCH 062/128] Fix formatting (#10668) Signed-off-by: Artem Savchenko --- foundations/core/packages/core/src/__tests__/query.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/core/src/__tests__/query.test.ts b/foundations/core/packages/core/src/__tests__/query.test.ts index e7c06bebb8..781017c149 100644 --- a/foundations/core/packages/core/src/__tests__/query.test.ts +++ b/foundations/core/packages/core/src/__tests__/query.test.ts @@ -17,7 +17,8 @@ import { findProperty } from '../query' import type { Doc, Ref, Class } from '../classes' function doc (id: string, fields: Record = {}): Doc { - return { _id: id as Ref, _class: 'test:class:Issue' as Ref>, ...fields } as Doc + const doc: Doc = { _id: id as Ref, _class: 'test:class:Issue' as Ref>, ...fields } as any + return doc } describe('findProperty', () => { From d26924408c6c2a461a7e54da4d861a21d92319d6 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 23 Mar 2026 14:56:03 +0700 Subject: [PATCH 063/128] Fix message links (#10660) * Merge with develop Signed-off-by: Artem Savchenko * Fix errors Signed-off-by: Artem Savchenko * Fix formatting Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko Co-authored-by: Kristina --- common/config/rush/pnpm-lock.yaml | 6 ++ models/activity/src/index.ts | 4 ++ plugins/activity-resources/src/activity.ts | 9 +++ .../ActivityMessageTemplate.svelte | 10 ++- .../ActivityMessageTooltip.svelte | 21 +++++++ plugins/activity-resources/src/index.ts | 6 +- plugins/activity-resources/src/utils.ts | 29 ++++++++- plugins/activity/src/index.ts | 10 ++- .../src/components/AttachmentRefInput.svelte | 7 ++- .../ReverseChannelScrollView.svelte | 13 ++-- .../ActivityInboxNotificationPresenter.svelte | 2 +- .../inbox/InboxNotificationPresenter.svelte | 12 +++- plugins/text-editor-resources/package.json | 1 + .../src/components/extension/reference.ts | 58 ++++++++++++----- plugins/view-resources/package.json | 1 + .../src/components/DocNavLink.svelte | 6 +- .../src/components/ObjectMention.svelte | 62 ++++++++++++++++--- 17 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 plugins/activity-resources/src/components/activity-message/ActivityMessageTooltip.svelte diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index f2566db58f..a8b3dfc6b8 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -28288,6 +28288,9 @@ importers: ../../plugins/text-editor-resources: dependencies: + '@hcengineering/activity': + specifier: workspace:^0.7.0 + version: link:../activity '@hcengineering/analytics': specifier: workspace:^0.7.17 version: link:../../foundations/core/packages/analytics @@ -29701,6 +29704,9 @@ importers: ../../plugins/view-resources: dependencies: + '@hcengineering/activity': + specifier: workspace:^0.7.0 + version: link:../activity '@hcengineering/analytics': specifier: workspace:^0.7.17 version: link:../../foundations/core/packages/analytics diff --git a/models/activity/src/index.ts b/models/activity/src/index.ts index 82b32c7fed..5bc99c74b3 100644 --- a/models/activity/src/index.ts +++ b/models/activity/src/index.ts @@ -390,6 +390,10 @@ export function createModel (builder: Builder): void { txClasses: [core.class.TxCreateDoc] }) + builder.mixin(activity.class.ActivityMessage, core.class.Class, view.mixin.ObjectTooltip, { + provider: activity.function.ActivityMessageTooltipProvider + }) + buildActions(builder) buildNotifications(builder) } diff --git a/plugins/activity-resources/src/activity.ts b/plugins/activity-resources/src/activity.ts index 5cf6e364e1..2b984737ce 100644 --- a/plugins/activity-resources/src/activity.ts +++ b/plugins/activity-resources/src/activity.ts @@ -16,6 +16,7 @@ import activity, { type ActivityMessage, type SavedMessage } from '@hcengineerin import core, { type Ref, SortingOrder, type WithLookup } from '@hcengineering/core' import { createQuery, onClient } from '@hcengineering/presentation' import { writable } from 'svelte/store' +import { getCurrentLocation, navigate } from '@hcengineering/ui' export const savedMessagesStore = writable>>([]) export const messageInFocus = writable | undefined>(undefined) @@ -23,6 +24,14 @@ export const editingMessageStore = writable | undefined>(un const savedMessagesQuery = createQuery(true) +export function clearMessageInLocation (): void { + const loc = getCurrentLocation() + if (loc.query?.message != null) { + delete loc.query.message + navigate(loc, true) + } +} + onClient(() => { savedMessagesQuery.query( activity.class.SavedMessage, diff --git a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte index 7dea1f37c7..34e98a6af9 100644 --- a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte +++ b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte @@ -29,7 +29,7 @@ import { Action as ViewAction } from '@hcengineering/view' import { getActions, restrictionStore, showMenu } from '@hcengineering/view-resources' - import { savedMessagesStore } from '../../activity' + import { clearMessageInLocation, savedMessagesStore } from '../../activity' import { MessageInlineAction } from '../../types' import ActivityMessageActions from '../ActivityMessageActions.svelte' import MessageTimestamp from '../MessageTimestamp.svelte' @@ -106,6 +106,13 @@ isActionsOpened = false } + function handleAnimationEnd (event: AnimationEvent): void { + const name = event.animationName.split('-').pop() + if (name === 'highlight') { + clearMessageInLocation() + } + } + $: key = parentMessage != null ? `${message._id}_${parentMessage._id}` : message._id $: isHidden = !!viewlet?.onlyWithParent && parentMessage === undefined @@ -180,6 +187,7 @@ class:stale on:click={onClick} on:contextmenu={handleContextMenu} + on:animationend={handleAnimationEnd} > {#if showNotify && !embedded && !isShort}
diff --git a/plugins/activity-resources/src/components/activity-message/ActivityMessageTooltip.svelte b/plugins/activity-resources/src/components/activity-message/ActivityMessageTooltip.svelte new file mode 100644 index 0000000000..6831edf506 --- /dev/null +++ b/plugins/activity-resources/src/components/activity-message/ActivityMessageTooltip.svelte @@ -0,0 +1,21 @@ + + +
+ +
+ + diff --git a/plugins/activity-resources/src/index.ts b/plugins/activity-resources/src/index.ts index 3214143f1f..ae881318d9 100644 --- a/plugins/activity-resources/src/index.ts +++ b/plugins/activity-resources/src/index.ts @@ -38,7 +38,8 @@ import { canSaveForLater, canUnpinMessage, removeFromSaved, - shouldScrollToActivity + shouldScrollToActivity, + activityMessageTooltipProvider } from './utils' export * from './types' @@ -88,7 +89,8 @@ export default async (): Promise => ({ CanRemoveFromSaved: canRemoveFromSaved, CanPinMessage: canPinMessage, CanUnpinMessage: canUnpinMessage, - ShouldScrollToActivity: shouldScrollToActivity + ShouldScrollToActivity: shouldScrollToActivity, + ActivityMessageTooltipProvider: activityMessageTooltipProvider }, backreference: { Update: updateReferences diff --git a/plugins/activity-resources/src/utils.ts b/plugins/activity-resources/src/utils.ts index 0f1c154667..12d18d2bbb 100644 --- a/plugins/activity-resources/src/utils.ts +++ b/plugins/activity-resources/src/utils.ts @@ -1,12 +1,21 @@ import type { ActivityMessage, Reaction } from '@hcengineering/activity' -import core, { getCurrentAccount, isOtherHour, type Doc, type Ref, type Space, type Blob } from '@hcengineering/core' +import core, { + getCurrentAccount, + isOtherHour, + type Doc, + type Ref, + type Space, + type Blob, + type TxOperations +} from '@hcengineering/core' import { getClient, isSpace } from '@hcengineering/presentation' import { closePopup, getCurrentResolvedLocation, getEventPositionElement, showPopup, - type Location + type Location, + type LabelAndProps } from '@hcengineering/ui' import { type AttributeModel } from '@hcengineering/view' import emojiPlugin from '@hcengineering/emoji' @@ -14,6 +23,7 @@ import { get } from 'svelte/store' import { savedMessagesStore } from './activity' import activity from './plugin' +import ActivityMessageTooltip from './components/activity-message/ActivityMessageTooltip.svelte' export async function updateDocReactions ( reactions: Reaction[], @@ -194,3 +204,18 @@ export function getActivityNewestFirst (): boolean { export function setActivityNewestFirst (value: boolean): void { localStorage.setItem(activityNewestFirstLocalStorageKey, JSON.stringify(value)) } + +export async function activityMessageTooltipProvider ( + _client: TxOperations, + doc?: ActivityMessage | null +): Promise { + if (doc == null) return undefined + + return { + component: ActivityMessageTooltip, + props: { value: doc }, + timeout: 300, + style: 'modern', + noArrow: true + } +} diff --git a/plugins/activity/src/index.ts b/plugins/activity/src/index.ts index f3a7892037..92ff57b0cb 100644 --- a/plugins/activity/src/index.ts +++ b/plugins/activity/src/index.ts @@ -26,12 +26,13 @@ import { Timestamp, Tx, TxCUD, - Blob + Blob, + Client } from '@hcengineering/core' import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { Preference } from '@hcengineering/preference' -import type { AnyComponent, ComponentExtensionId } from '@hcengineering/ui' +import type { AnyComponent, ComponentExtensionId, LabelAndProps } from '@hcengineering/ui' import type { Action } from '@hcengineering/view' /** @@ -348,7 +349,10 @@ export default plugin(activityId, { ActivityEmployeePresenter: '' as ComponentExtensionId }, function: { - ShouldScrollToActivity: '' as Resource<() => boolean> + ShouldScrollToActivity: '' as Resource<() => boolean>, + ActivityMessageTooltipProvider: '' as Resource< + (client: Client, doc?: Doc | null) => Promise + > }, backreference: { // Update list of back references diff --git a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte index f60068fc63..d97660caa0 100644 --- a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte +++ b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte @@ -99,6 +99,7 @@ const urlSet = new Set() let progress = false + let loadingLinks = false let refContainer: HTMLElement @@ -380,7 +381,7 @@ } async function loadLinks (urls: string[]): Promise { - progress = true + loadingLinks = true for (const url of urls) { try { const meta = await fetchLinkPreviewDetails(url) @@ -401,7 +402,7 @@ void setPlatformStatus(unknownError(err)) } } - progress = false + loadingLinks = false } async function loadFiles (evt: ClipboardEvent): Promise { @@ -492,7 +493,7 @@ {showSend} {showActions} autofocus={autofocus ? 'end' : false} - loading={loading || progress} + loading={loading || progress || loadingLinks} {boundary} {docClass} extraActions={[ diff --git a/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte b/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte index 470217dbae..fd878caece 100644 --- a/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte @@ -18,7 +18,8 @@ ActivityMessagePresenter, canGroupMessages, messageInFocus, - editingMessageStore + editingMessageStore, + clearMessageInLocation } from '@hcengineering/activity-resources' import core, { Doc, generateId, getCurrentAccount, Ref, Space, Timestamp, Tx, TxCUD } from '@hcengineering/core' import { DocNotifyContext } from '@hcengineering/notification' @@ -131,8 +132,8 @@ } }) - $: void initializeScroll($isLoadingStore, separatorDiv, separatorIndex) $: adjustScrollPosition(selectedMessageId) + $: void initializeScroll($isLoadingStore, separatorDiv, separatorIndex) $: void handleMessagesUpdated(messages.length) function adjustScrollPosition (selectedMessageId?: Ref): void { @@ -147,9 +148,6 @@ } else { scrollToMessage() } - } else if (selectedMessageId === undefined) { - provider.jumpToEnd() - reinitializeScroll() } } @@ -392,6 +390,7 @@ async function handleScrollToLatestMessage (): Promise { selectedMessageId = undefined messageInFocus.set(undefined) + clearMessageInLocation() const metadata = $metadataStore const lastMetadata = metadata[metadata.length - 1] @@ -579,7 +578,9 @@ $: showBlankView = !$isLoadingStore && messages.length === 0 && !isThread export function editLastMessage (): void { - if ($isLoadingStore || !isScrollInitialized || !$isTailLoadedStore || scrollDiv == null) return + if ($isLoadingStore || !isScrollInitialized || !$isTailLoadedStore || scrollDiv == null) { + return + } if (!isScrollAtBottom) return const me = getCurrentAccount() let lastMessage: ChatMessage | undefined = undefined diff --git a/plugins/notification-resources/src/components/inbox/ActivityInboxNotificationPresenter.svelte b/plugins/notification-resources/src/components/inbox/ActivityInboxNotificationPresenter.svelte index 6c33a6f013..10f557ff71 100644 --- a/plugins/notification-resources/src/components/inbox/ActivityInboxNotificationPresenter.svelte +++ b/plugins/notification-resources/src/components/inbox/ActivityInboxNotificationPresenter.svelte @@ -119,6 +119,6 @@ on:click /> {:else} - + {/if} {/if} diff --git a/plugins/notification-resources/src/components/inbox/InboxNotificationPresenter.svelte b/plugins/notification-resources/src/components/inbox/InboxNotificationPresenter.svelte index 22f99b500f..4b2e90e4a1 100644 --- a/plugins/notification-resources/src/components/inbox/InboxNotificationPresenter.svelte +++ b/plugins/notification-resources/src/components/inbox/InboxNotificationPresenter.svelte @@ -54,11 +54,17 @@ {#if hierarchy.isDerived(value._class, notification.class.ActivityInboxNotification)} - + {:else if hierarchy.isDerived(value._class, notification.class.MentionInboxNotification)} - + {:else if hierarchy.isDerived(value._class, notification.class.ReactionInboxNotification)} - + {:else if hierarchy.isDerived(value._class, notification.class.CommonInboxNotification)} {/if} diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index 653283bf0e..94dfc604c0 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -99,6 +99,7 @@ "mermaid": "^11.12.0", "@hcengineering/theme": "workspace:^0.7.0", "tippy.js": "~6.3.7", + "@hcengineering/activity": "workspace:^0.7.0", "@hcengineering/chunter": "workspace:^0.7.0", "@tiptap/extension-text-align": "~2.11.0", "@tiptap/extension-mathematics": "^2.11.7", diff --git a/plugins/text-editor-resources/src/components/extension/reference.ts b/plugins/text-editor-resources/src/components/extension/reference.ts index 64f6a1dff6..d927118cbc 100644 --- a/plugins/text-editor-resources/src/components/extension/reference.ts +++ b/plugins/text-editor-resources/src/components/extension/reference.ts @@ -25,7 +25,7 @@ import { type Blob, type Class, type Doc, type Ref } from '@hcengineering/core' import { getMetadata, getResource, translate } from '@hcengineering/platform' import presentation, { createQuery, getBlobRef, getClient, MessageBox } from '@hcengineering/presentation' import view from '@hcengineering/view' - +import activity, { type ActivityMessage } from '@hcengineering/activity' import contact from '@hcengineering/contact' import { parseLocation, showPopup, tooltip, type LabelAndProps, type Location, fromCodePoint } from '@hcengineering/ui' import workbench, { type Application } from '@hcengineering/workbench' @@ -422,6 +422,26 @@ async function getReferenceTooltip ( return { label: hierarchy.getClass(objectclass).label } } +async function getDocLabel (_id: Ref, _class: Ref>, doc?: Doc): Promise { + const client = getClient() + const hierarchy = client.getHierarchy() + + const labelProvider = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIdentifier) + const labelProviderFn = labelProvider !== undefined ? await getResource(labelProvider.provider) : undefined + + const titleMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectTitle) + const titleProviderFn = titleMixin !== undefined ? await getResource(titleMixin.titleProvider) : undefined + + const identifier = (await labelProviderFn?.(client, _id, doc)) ?? '' + const title = (await titleProviderFn?.(client, _id, doc)) ?? '' + + return identifier !== '' && title !== '' && identifier !== title + ? `${identifier} ${title}` + : title !== '' + ? title + : identifier +} + export async function getReferenceLabel ( objectclass: Ref>, id: Ref, @@ -430,23 +450,21 @@ export async function getReferenceLabel ( const client = getClient() const hierarchy = client.getHierarchy() - const labelProvider = hierarchy.classHierarchyMixin(objectclass as Ref>, view.mixin.ObjectIdentifier) - const labelProviderFn = labelProvider !== undefined ? await getResource(labelProvider.provider) : undefined + if (hierarchy.isDerived(objectclass, activity.class.ActivityMessage)) { + const message = + (doc as any as ActivityMessage) ?? + (await client.findOne(activity.class.ActivityMessage, { _id: id as any as Ref })) + if (message === undefined) return '' - const titleMixin = hierarchy.classHierarchyMixin(objectclass as Ref>, view.mixin.ObjectTitle) - const titleProviderFn = titleMixin !== undefined ? await getResource(titleMixin.titleProvider) : undefined + const attachedToDoc = await client.findOne(message.attachedToClass, { _id: message.attachedTo }) + if (attachedToDoc === undefined) return '' - const identifier = (await labelProviderFn?.(client, id, doc)) ?? '' - const title = (await titleProviderFn?.(client, id, doc)) ?? '' + const label = await getDocLabel(attachedToDoc._id, attachedToDoc._class, attachedToDoc) - const label = - identifier !== '' && title !== '' && identifier !== title - ? `${identifier} ${title}` - : title !== '' - ? title - : identifier + return `${label}?message=${message._id}` + } - return label + return await getDocLabel(id, objectclass, doc) } export async function getReferenceObject ( @@ -457,6 +475,14 @@ export async function getReferenceObject ( const client = getClient() const hierarchy = client.getHierarchy() + if (objectclass === activity.class.ActivityMessage) { + const message: ActivityMessage | undefined = + (doc as any as ActivityMessage) ?? + (await client.findOne(activity.class.ActivityMessage, { _id: id as any as Ref })) + if (message === undefined) return undefined + return message + } + const referenceObjectProvider = hierarchy.classHierarchyMixin( objectclass as Ref>, view.mixin.ReferenceObjectProvider @@ -512,6 +538,10 @@ export async function getTargetObjectFromUrl ( const app = apps.find((p) => p.alias === appAlias) const locationResolver = app?.locationResolver const locationDataResolver = app?.locationDataResolver + const messageId = location?.query?.message ?? '' + if (messageId !== '') { + return { _id: messageId as Ref, _class: activity.class.ActivityMessage } + } if ((location.fragment ?? '') !== '') { const obj = await getObjectFromFragment(location.fragment ?? '') diff --git a/plugins/view-resources/package.json b/plugins/view-resources/package.json index 5bef5c43a1..455666e3e2 100644 --- a/plugins/view-resources/package.json +++ b/plugins/view-resources/package.json @@ -57,6 +57,7 @@ "@hcengineering/notification": "workspace:^0.7.0", "@hcengineering/presentation": "workspace:^0.7.0", "@hcengineering/card": "workspace:^0.7.0", + "@hcengineering/activity": "workspace:^0.7.0", "@hcengineering/setting": "workspace:^0.7.0", "@hcengineering/text-editor": "workspace:^0.7.0", "@hcengineering/text-editor-resources": "workspace:^0.7.0", diff --git a/plugins/view-resources/src/components/DocNavLink.svelte b/plugins/view-resources/src/components/DocNavLink.svelte index 298566f0a7..9ab21a0569 100644 --- a/plugins/view-resources/src/components/DocNavLink.svelte +++ b/plugins/view-resources/src/components/DocNavLink.svelte @@ -36,6 +36,7 @@ export let inlineBlock = false export let noSelect: boolean = true export let title: string | undefined = undefined + export let query: Record | undefined = undefined const docQuery = createQuery() const client = getClient() @@ -66,7 +67,10 @@ const comp = panelComponent?.component ?? component const loc = await getObjectLinkFragment(hierarchy, object, props, comp) const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin - href = concatLink(frontUrl, locationToUrl(loc)) + + href = query + ? `${concatLink(frontUrl, locationToUrl(loc))}?${new URLSearchParams(query).toString()}` + : concatLink(frontUrl, locationToUrl(loc)) } $: if (object !== undefined) getHref(object) diff --git a/plugins/view-resources/src/components/ObjectMention.svelte b/plugins/view-resources/src/components/ObjectMention.svelte index d7ad8804fe..1aec343bca 100644 --- a/plugins/view-resources/src/components/ObjectMention.svelte +++ b/plugins/view-resources/src/components/ObjectMention.svelte @@ -18,6 +18,7 @@ import { createQuery, getClient, IconWithEmoji } from '@hcengineering/presentation' import { AnyComponent, Icon, LabelAndProps, themeStore, tooltip } from '@hcengineering/ui' import view from '@hcengineering/view' + import activity, { ActivityMessage } from '@hcengineering/activity' import { getReferenceLabel } from '@hcengineering/text-editor-resources/src/components/extension/reference' import { classIcon } from '../utils' @@ -37,6 +38,7 @@ const hierarchy = client.getHierarchy() const docQuery = createQuery() + let parentDoc: Doc | undefined = undefined let doc: Doc | undefined = object ?? undefined let docLabel: string = '' @@ -49,7 +51,7 @@ let displayTitle = '' $: displayTitle = docTitle || title || docLabel - $: docComponent = getPanelComponent(doc, _class) + $: docComponent = getPanelComponent(parentDoc ?? doc, _class) $: if (object == null && _class != null && _id != null) { docQuery.query(_class, { _id }, (r) => { @@ -60,24 +62,54 @@ doc = object } - $: cl = doc?._class ?? _class + $: void updateParentDoc(doc, _class) + + async function updateParentDoc (doc: Doc | undefined, _class: Ref> | undefined): Promise { + const resultClass = doc?._class ?? _class + if (resultClass == null) { + parentDoc = undefined + return + } + + if (hierarchy.isDerived(resultClass, activity.class.ActivityMessage)) { + const message = doc as ActivityMessage + if (parentDoc?._id === message.attachedTo) return + parentDoc = await client.findOne(message.attachedToClass, { _id: message.attachedTo }) + } else { + parentDoc = undefined + } + } + + $: docClass = doc?._class ?? _class + $: docId = doc?._id ?? _id + + $: cl = parentDoc?._class ?? docClass $: clazz = cl ? hierarchy.findClass(cl) : undefined - $: icon = - doc !== undefined && !hierarchy.isDerived(doc._class, contact.class.Contact) ? classIcon(client, doc._class) : null + $: icon = getIcon(doc) $: void updateDocTitle(doc) $: void updateDocTooltip(doc) - $: void updateDocLabel(doc, _class) + $: void updateDocLabel(parentDoc ?? doc, _class) + + function getIcon (doc: Doc | undefined): any { + if (doc == null) return undefined + if (hierarchy.isDerived(doc._class, contact.class.Contact)) return undefined + + return classIcon(client, doc._class) + } function getPanelComponent (doc?: Doc, _class?: Ref>): AnyComponent { - if (component !== undefined) { - return component - } - + if (component !== undefined) return component const resultClass = doc?._class ?? _class if (resultClass === undefined) { return view.component.EditDoc + } else if (hierarchy.isDerived(resultClass, activity.class.ActivityMessage)) { + if (doc == null) return view.component.EditDoc + const message = doc as ActivityMessage + const panelComponent = hierarchy.classHierarchyMixin(message.attachedToClass, view.mixin.ObjectPanel) + + return panelComponent?.component ?? view.component.EditDoc } else { const panelComponent = hierarchy.classHierarchyMixin(resultClass, view.mixin.ObjectPanel) @@ -127,7 +159,17 @@ data-label={displayTitle} use:tooltip={docTooltip} > - + {#if icon}{#if icon === view.ids.IconWithEmoji} Date: Mon, 23 Mar 2026 15:33:06 +0700 Subject: [PATCH 064/128] Try to fix unstable 'find avg' tests (#10669) * Try to fix unstable 'find avg' tests Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- ws-tests/api-tests/src/__tests__/rest.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ws-tests/api-tests/src/__tests__/rest.test.ts b/ws-tests/api-tests/src/__tests__/rest.test.ts index ef7e10e6fc..1de19cdc51 100644 --- a/ws-tests/api-tests/src/__tests__/rest.test.ts +++ b/ws-tests/api-tests/src/__tests__/rest.test.ts @@ -175,12 +175,12 @@ describe('rest-api-server', () => { it('find avg', async () => { const conn = connect() await checkFindPerformance(conn) // 5ms max per operation - }) + }, 10000) it('find avg-europe', async () => { const conn = connect(apiWorkspace2) await checkFindPerformance(conn) // 5ms max per operation - }) + }, 10000) it('add space', async () => { const conn = connect() From a2e3a89132dd98de834acaa2bc340acbdedacc55 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Mon, 23 Mar 2026 17:53:32 +0500 Subject: [PATCH 065/128] Fix process card update check for tags (#10671) Signed-off-by: Denis Bykhov --- plugins/process-resources/src/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/process-resources/src/utils.ts b/plugins/process-resources/src/utils.ts index 006289a13e..9234d7d3df 100644 --- a/plugins/process-resources/src/utils.ts +++ b/plugins/process-resources/src/utils.ts @@ -769,8 +769,13 @@ export function matchCardCheck ( params: Record, context: Record ): boolean { - const doc = context.card + let doc = context.card if (doc === undefined) return false + const process = client.getModel().findObject(execution.process) + if (process === undefined) return false + if (client.getHierarchy().isMixin(process.masterTag)) { + doc = client.getHierarchy().as(doc, process.masterTag) + } const res = matchQuery([doc], params, doc._class, client.getHierarchy(), true) return res.length > 0 } From 6f14b3d73aa27998840606526bc63af32368db9d Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Mon, 23 Mar 2026 17:56:05 +0500 Subject: [PATCH 066/128] =?UTF-8?q?feat:=20Add=20new=20process=20functions?= =?UTF-8?q?=20for=20data=20type=20conversion,=20including=20s=E2=80=A6=20(?= =?UTF-8?q?#10670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Denis Bykhov --- models/process/src/functions.ts | 130 ++++++++++++++++++ models/process/src/index.ts | 36 +++-- models/server-process/src/index.ts | 40 ++++++ plugins/process-assets/lang/cs.json | 12 +- plugins/process-assets/lang/de.json | 12 +- plugins/process-assets/lang/en.json | 14 +- plugins/process-assets/lang/es.json | 12 +- plugins/process-assets/lang/fr.json | 12 +- plugins/process-assets/lang/it.json | 12 +- plugins/process-assets/lang/ja.json | 12 +- plugins/process-assets/lang/pt-br.json | 12 +- plugins/process-assets/lang/pt.json | 12 +- plugins/process-assets/lang/ru.json | 12 +- plugins/process-assets/lang/tr.json | 10 +- plugins/process-assets/lang/zh.json | 12 +- .../ContextSelectorPopup.svelte | 110 ++++++++++++++- .../criterias/ArraySizeCriteria.svelte | 2 +- .../criterias/AttributeCriteria.svelte | 2 +- .../settings/AddRelationEditor.svelte | 2 +- .../settings/ApproveRequestEditor.svelte | 2 +- .../settings/ProcessAttributeEditor.svelte | 2 +- .../src/components/settings/TimeEditor.svelte | 2 +- .../ArrayElementEditor.svelte | 2 +- plugins/process-resources/src/index.ts | 4 +- plugins/process-resources/src/plugin.ts | 13 +- plugins/process-resources/src/utils.ts | 44 ++++-- plugins/process/src/index.ts | 13 +- plugins/process/src/types.ts | 4 + server-plugins/process-resources/src/index.ts | 24 +++- .../process-resources/src/transform.ts | 48 +++++++ server-plugins/process/src/index.ts | 12 +- 31 files changed, 584 insertions(+), 52 deletions(-) diff --git a/models/process/src/functions.ts b/models/process/src/functions.ts index 6dc4ac78d8..a3c6def254 100644 --- a/models/process/src/functions.ts +++ b/models/process/src/functions.ts @@ -544,4 +544,134 @@ export function defineFunctions (builder: Builder): void { }, process.function.CurrentDate ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + to: core.class.TypeString, + category: 'attribute', + label: process.string.TextFromNumber, + type: 'convert' + }, + process.function.StringFromNumber + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + to: core.class.TypeString, + category: 'attribute', + label: process.string.TextFromDate, + type: 'convert' + }, + process.function.StringFromDate + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeBoolean, + to: core.class.TypeString, + category: 'attribute', + label: process.string.TextFromCheckbox, + type: 'convert' + }, + process.function.StringFromBoolean + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + to: core.class.TypeNumber, + category: 'attribute', + label: process.string.NumberFromDate, + type: 'convert' + }, + process.function.NumberFromDate + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + to: core.class.TypeDate, + category: 'attribute', + label: process.string.DateFromNumber, + type: 'convert' + }, + process.function.DateFromNumber + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + to: core.class.TypeNumber, + category: 'attribute', + label: process.string.NumberFromText, + type: 'convert' + }, + process.function.NumberFromString + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + to: core.class.TypeDate, + category: 'attribute', + label: process.string.DateFromText, + type: 'convert' + }, + process.function.DateFromString + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + to: core.class.TypeNumber, + category: 'attribute', + label: process.string.YearFromDate, + type: 'convert' + }, + process.function.YearFromDate + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + to: core.class.TypeNumber, + category: 'attribute', + label: process.string.MonthFromDate, + type: 'convert' + }, + process.function.MonthFromDate + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + to: core.class.TypeNumber, + category: 'attribute', + label: process.string.DayFromDate, + type: 'convert' + }, + process.function.DayFromDate + ) } diff --git a/models/process/src/index.ts b/models/process/src/index.ts index 6ba0a1ab06..5d1b9008a1 100644 --- a/models/process/src/index.ts +++ b/models/process/src/index.ts @@ -33,6 +33,8 @@ import { ReadOnly, TypeAny, TypeBoolean, + TypeIntlString, + TypeRecord, TypeRank, TypeRef, TypeString, @@ -44,7 +46,7 @@ import { TToDo } from '@hcengineering/model-time' import view, { createAction } from '@hcengineering/model-view' import workbench from '@hcengineering/model-workbench' import notification, { type NotificationGroup } from '@hcengineering/notification' -import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' +import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform' import { type ApproveRequest, type CheckFunc, @@ -285,24 +287,40 @@ export class TEventButton extends TDoc implements EventButton { @Model(process.class.ProcessFunction, core.class.Doc, DOMAIN_MODEL) export class TProcessFunction extends TDoc implements ProcessFunction { - of!: Ref>> - category: AttributeCategory | undefined - label!: IntlString + @Prop(TypeRef(core.class.Class), getEmbeddedLabel('To')) + to?: Ref> + + @Prop(TypeRef(core.class.Class), getEmbeddedLabel('Of')) + of!: Ref>> + + @Prop(TypeString(), getEmbeddedLabel('Category')) + category: AttributeCategory | undefined + + @Prop(TypeIntlString(), getEmbeddedLabel('Label')) + label!: IntlString + editor?: AnyComponent presenter?: AnyComponent - allowMany?: boolean - type!: 'transform' | 'reduce' | 'context' + + @Prop(TypeBoolean(), getEmbeddedLabel('AllowMany')) + allowMany?: boolean + + @Prop(TypeString(), getEmbeddedLabel('Type')) + type!: 'transform' | 'reduce' | 'context' | 'convert' } @Model(process.class.UpdateCriteriaComponent, core.class.Doc, DOMAIN_MODEL) export class TUpdateCriteriaComponent extends TDoc implements UpdateCriteriaComponent { - category!: AttributeCategory + @Prop(TypeString(), getEmbeddedLabel('Category')) + category!: AttributeCategory editor!: AnyComponent - of!: Ref>> + @Prop(TypeRef(core.class.Class), getEmbeddedLabel('Of')) + of!: Ref>> - props!: Record + @Prop(TypeRecord(), getEmbeddedLabel('Props')) + props!: Record } export * from './migration' diff --git a/models/server-process/src/index.ts b/models/server-process/src/index.ts index d85d2f234d..56daf790e1 100644 --- a/models/server-process/src/index.ts +++ b/models/server-process/src/index.ts @@ -171,6 +171,46 @@ export function createModel (builder: Builder): void { func: serverProcess.transform.LastValue }) + builder.mixin(process.function.StringFromNumber, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.StringFromNumber + }) + + builder.mixin(process.function.StringFromDate, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.StringFromDate + }) + + builder.mixin(process.function.StringFromBoolean, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.StringFromBoolean + }) + + builder.mixin(process.function.NumberFromDate, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.NumberFromDate + }) + + builder.mixin(process.function.DateFromNumber, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.DateFromNumber + }) + + builder.mixin(process.function.NumberFromString, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.NumberFromString + }) + + builder.mixin(process.function.DateFromString, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.DateFromString + }) + + builder.mixin(process.function.YearFromDate, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.YearFromDate + }) + + builder.mixin(process.function.MonthFromDate, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.MonthFromDate + }) + + builder.mixin(process.function.DayFromDate, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.DayFromDate + }) + builder.mixin(process.function.Random, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { func: serverProcess.transform.Random }) diff --git a/plugins/process-assets/lang/cs.json b/plugins/process-assets/lang/cs.json index 52761a5454..d1c1c89853 100644 --- a/plugins/process-assets/lang/cs.json +++ b/plugins/process-assets/lang/cs.json @@ -152,7 +152,17 @@ "ReviewAction": "Zkontrolovat", "Review": "Zkontrolovat", "LockField": "Zamknout pole", - "UnlockField": "Odemknout pole" + "UnlockField": "Odemknout pole", + "TextFromNumber": "Text z čísla", + "TextFromDate": "Text z data", + "TextFromCheckbox": "Text z checkboxu", + "NumberFromDate": "Číslo z data", + "DateFromNumber": "Datum z čísla", + "NumberFromText": "Číslo ze stringu", + "DateFromText": "Datum ze stringu", + "YearFromDate": "Rok z data", + "MonthFromDate": "Měsíc z data", + "DayFromDate": "Den z data" }, "error": { "MethodNotFound": "Metoda nenalezena: {methodId}", diff --git a/plugins/process-assets/lang/de.json b/plugins/process-assets/lang/de.json index 1d605074b3..44c1c6809f 100644 --- a/plugins/process-assets/lang/de.json +++ b/plugins/process-assets/lang/de.json @@ -152,7 +152,17 @@ "ReviewAction": "Prüfen", "Review": "Prüfen", "LockField": "Feld sperren", - "UnlockField": "Feld entsperren" + "UnlockField": "Feld entsperren", + "TextFromNumber": "Text aus Zahl", + "TextFromDate": "Text aus Datum", + "TextFromCheckbox": "Text aus Checkbox", + "NumberFromDate": "Zahl aus Datum", + "DateFromNumber": "Datum aus Zahl", + "NumberFromText": "Zahl aus Text", + "DateFromText": "Datum aus Text", + "YearFromDate": "Jahr aus Datum", + "MonthFromDate": "Monat aus Datum", + "DayFromDate": "Tag aus Datum" }, "error": { "MethodNotFound": "Methode nicht gefunden: {methodId}", diff --git a/plugins/process-assets/lang/en.json b/plugins/process-assets/lang/en.json index 35fb1c3fd3..a310314daf 100644 --- a/plugins/process-assets/lang/en.json +++ b/plugins/process-assets/lang/en.json @@ -157,12 +157,22 @@ "ReviewAction": "Review", "Review": "Review", "LockField": "Lock field", - "UnlockField": "Unlock field" + "UnlockField": "Unlock field", + "TextFromNumber": "Text from number", + "TextFromDate": "Text from date", + "TextFromCheckbox": "Text from checkbox", + "NumberFromDate": "Number from date", + "DateFromNumber": "Date from number", + "NumberFromText": "Number from text", + "DateFromText": "Date from text", + "YearFromDate": "Year from date", + "MonthFromDate": "Month from date", + "DayFromDate": "Day from date" }, "error": { "MethodNotFound": "Method not found: {methodId}", "AttributeNotExists": "Attribute not exists: {key}", - "RelatedObjectNotFound": "Related object not found: {attr}", + "RelatedObjectNotFound": "Related object not f ound: {attr}", "RelationNotExists": "Relation not exists: {association}", "EmptyAttributeContextValue": "Empty attribute value: {attr}", "UserRequestedValueNotProvided": "User requested value not provided: {attr}", diff --git a/plugins/process-assets/lang/es.json b/plugins/process-assets/lang/es.json index 1166a2fdef..f85aa11589 100644 --- a/plugins/process-assets/lang/es.json +++ b/plugins/process-assets/lang/es.json @@ -157,7 +157,17 @@ "ReviewAction": "Revisar", "Review": "Revisar", "LockField": "Bloquear campo", - "UnlockField": "Desbloquear campo" + "UnlockField": "Desbloquear campo", + "TextFromNumber": "Texto desde número", + "TextFromDate": "Texto desde fecha", + "TextFromCheckbox": "Texto desde checkbox", + "NumberFromDate": "Número desde fecha", + "DateFromNumber": "Fecha desde número", + "NumberFromText": "Número desde texto", + "DateFromText": "Fecha desde texto", + "YearFromDate": "Año desde fecha", + "MonthFromDate": "Mes desde fecha", + "DayFromDate": "Día desde fecha" }, "error": { "MethodNotFound": "Método no encontrado: {methodId}", diff --git a/plugins/process-assets/lang/fr.json b/plugins/process-assets/lang/fr.json index 03187830d5..ab333ee6be 100644 --- a/plugins/process-assets/lang/fr.json +++ b/plugins/process-assets/lang/fr.json @@ -157,7 +157,17 @@ "ReviewAction": "Revoir", "Review": "Revoir", "LockField": "Verrouiller le champ", - "UnlockField": "Déverrouiller le champ" + "UnlockField": "Déverrouiller le champ", + "TextFromNumber": "Texte depuis nombre", + "TextFromDate": "Texte depuis date", + "TextFromCheckbox": "Texte depuis checkbox", + "NumberFromDate": "Nombre depuis date", + "DateFromNumber": "Date depuis nombre", + "NumberFromText": "Nombre depuis texte", + "DateFromText": "Date depuis texte", + "YearFromDate": "Année depuis date", + "MonthFromDate": "Mois depuis date", + "DayFromDate": "Jour depuis date" }, "error": { "MethodNotFound": "Méthode introuvable : {methodId}", diff --git a/plugins/process-assets/lang/it.json b/plugins/process-assets/lang/it.json index 9aeccb2b83..1618790ed0 100644 --- a/plugins/process-assets/lang/it.json +++ b/plugins/process-assets/lang/it.json @@ -157,7 +157,17 @@ "ReviewAction": "Revisa", "Review": "Revisa", "LockField": "Blocca campo", - "UnlockField": "Sblocca campo" + "UnlockField": "Sblocca campo", + "TextFromNumber": "Testo da numero", + "TextFromDate": "Testo da data", + "TextFromCheckbox": "Testo da checkbox", + "NumberFromDate": "Numero da data", + "DateFromNumber": "Data da numero", + "NumberFromText": "Numero da testo", + "DateFromText": "Data da testo", + "YearFromDate": "Anno da data", + "MonthFromDate": "Mese da data", + "DayFromDate": "Giorno da data" }, "error": { "MethodNotFound": "Metodo non trovato: {methodId}", diff --git a/plugins/process-assets/lang/ja.json b/plugins/process-assets/lang/ja.json index c0f302bbdd..fcf7c29ac6 100644 --- a/plugins/process-assets/lang/ja.json +++ b/plugins/process-assets/lang/ja.json @@ -156,7 +156,17 @@ "ReviewAction": "レビュー", "Review": "レビュー", "LockField": "フィールドをロック", - "UnlockField": "フィールドのロックを解除" + "UnlockField": "フィールドのロックを解除", + "TextFromNumber": "テキストから数値", + "TextFromDate": "テキストから日付", + "TextFromCheckbox": "チェックボックスからテキスト", + "NumberFromDate": "日付から数値", + "DateFromNumber": "数値から日付", + "NumberFromText": "テキストから数値", + "DateFromText": "テキストから日付", + "YearFromDate": "日付から年", + "MonthFromDate": "日付から月", + "DayFromDate": "日付から日" }, "error": { "MethodNotFound": "メソッドが見つかりません: {methodId}", diff --git a/plugins/process-assets/lang/pt-br.json b/plugins/process-assets/lang/pt-br.json index df361c1fa7..9439b5c930 100644 --- a/plugins/process-assets/lang/pt-br.json +++ b/plugins/process-assets/lang/pt-br.json @@ -145,7 +145,17 @@ "ReviewAction": "Revisar", "Review": "Revisar", "LockField": "Bloquear campo", - "UnlockField": "Desbloquear campo" + "UnlockField": "Desbloquear campo", + "TextFromNumber": "Texto de número", + "TextFromDate": "Texto de data", + "TextFromCheckbox": "Texto de checkbox", + "NumberFromDate": "Número de data", + "DateFromNumber": "Data de número", + "NumberFromText": "Número de texto", + "DateFromText": "Data de texto", + "YearFromDate": "Ano de data", + "MonthFromDate": "Mês de data", + "DayFromDate": "Dia de data" }, "error": { "MethodNotFound": "Método não encontrado: {methodId}", diff --git a/plugins/process-assets/lang/pt.json b/plugins/process-assets/lang/pt.json index e0444f822e..d0e6af3cad 100644 --- a/plugins/process-assets/lang/pt.json +++ b/plugins/process-assets/lang/pt.json @@ -157,7 +157,17 @@ "ReviewAction": "Revisar", "Review": "Revisar", "LockField": "Bloquear campo", - "UnlockField": "Desbloquear campo" + "UnlockField": "Desbloquear campo", + "TextFromNumber": "Texto de número", + "TextFromDate": "Texto de data", + "TextFromCheckbox": "Texto de checkbox", + "NumberFromDate": "Número de data", + "DateFromNumber": "Data de número", + "NumberFromText": "Número de texto", + "DateFromText": "Data de texto", + "YearFromDate": "Ano de data", + "MonthFromDate": "Mês de data", + "DayFromDate": "Dia de data" }, "error": { "MethodNotFound": "Método não encontrado: {methodId}", diff --git a/plugins/process-assets/lang/ru.json b/plugins/process-assets/lang/ru.json index db74447113..c6c1a4390e 100644 --- a/plugins/process-assets/lang/ru.json +++ b/plugins/process-assets/lang/ru.json @@ -157,7 +157,17 @@ "ReviewAction": "Рецензирование", "Review": "Рецензировать", "LockField": "Заблокировать поле", - "UnlockField": "Разблокировать поле" + "UnlockField": "Разблокировать поле", + "TextFromNumber": "Строка из числа", + "TextFromDate": "Строка из даты", + "TextFromCheckbox": "Строка из чекбокса", + "NumberFromDate": "Число из даты", + "DateFromNumber": "Дата из числа", + "NumberFromString": "Число из строки", + "DateFromString": "Дата из строки", + "YearFromDate": "Год из даты", + "MonthFromDate": "Месяц из даты", + "DayFromDate": "День из даты" }, "error": { "MethodNotFound": "Метод не найден: {methodId}", diff --git a/plugins/process-assets/lang/tr.json b/plugins/process-assets/lang/tr.json index 77876e86ed..a9154070c7 100644 --- a/plugins/process-assets/lang/tr.json +++ b/plugins/process-assets/lang/tr.json @@ -154,7 +154,15 @@ "ReviewAction": "Gözden geçir", "Review": "Gözden geçir", "LockField": "Alanı kilitle", - "UnlockField": "Alanı kilitle" + "UnlockField": "Alanı kilitle", + "TextFromCheckbox": "Checkbox'tan metin", + "NumberFromDate": "Tarihten sayı", + "DateFromNumber": "Sayıdan tarih", + "NumberFromText": "Metinden sayı", + "DateFromText": "Metinden tarih", + "YearFromDate": "Tarihten yıl", + "MonthFromDate": "Tarihten ay", + "DayFromDate": "Tarihten gün" }, "error": { "MethodNotFound": "Metod bulunamadı: {methodId}", diff --git a/plugins/process-assets/lang/zh.json b/plugins/process-assets/lang/zh.json index bbd15db200..e911b8a14b 100644 --- a/plugins/process-assets/lang/zh.json +++ b/plugins/process-assets/lang/zh.json @@ -157,7 +157,17 @@ "ReviewAction": "审查", "Review": "审查", "LockField": "锁定字段", - "UnlockField": "解锁字段" + "UnlockField": "解锁字段", + "TextFromNumber": "数字到文本", + "TextFromDate": "日期到文本", + "TextFromCheckbox": "复选框到文本", + "NumberFromDate": "日期到数字", + "DateFromNumber": "数字到日期", + "NumberFromText": "文本到数字", + "DateFromText": "文本到日期", + "YearFromDate": "日期到年份", + "MonthFromDate": "日期到月份", + "DayFromDate": "日期到天" }, "error": { "MethodNotFound": "找不到方法:{methodId}", diff --git a/plugins/process-resources/src/components/attributeEditors/ContextSelectorPopup.svelte b/plugins/process-resources/src/components/attributeEditors/ContextSelectorPopup.svelte index 7eeef92ef7..a1d920cf1e 100644 --- a/plugins/process-resources/src/components/attributeEditors/ContextSelectorPopup.svelte +++ b/plugins/process-resources/src/components/attributeEditors/ContextSelectorPopup.svelte @@ -58,6 +58,7 @@ $: nested = Object.values(context.nested) $: relations = Object.entries(context.relations) + $: convertible = (context as any).convertible ?? [] function onUserRequest (): void { onSelect({ @@ -117,12 +118,55 @@ dispatch('close') }) } + + function onConvertSelect (val: SelectedContext | null, func: Ref): void { + if (val !== null) { + onClick({ + ...val, + functions: [{ func, props: {} }, ...(val.functions ?? [])] + }) + } + } + + const elements: HTMLElement[] = [] + + const keyDown = (event: KeyboardEvent, index: number): void => { + if (event.key === 'ArrowDown') { + elements[(index + 1) % elements.length]?.focus() + } + + if (event.key === 'ArrowUp') { + elements[(elements.length + index - 1) % elements.length]?.focus() + } + + if (event.key === 'ArrowLeft') { + dispatch('close') + } + } + + function getOnConvertSelect (func: Ref): (val: SelectedContext | null) => void { + return (val: SelectedContext | null) => { + onConvertSelect(val, func) + } + } + + $: functionsOffset = 1 + $: processContextOffset = functionsOffset + context.functions.length + $: attributesOffset = processContextOffset + processContext.length + $: nestedOffset = attributesOffset + context.attributes.length + $: relationsOffset = nestedOffset + nested.length + $: convertibleOffset = relationsOffset + relations.length + $: customValueIndex = convertibleOffset + convertible.length
dispatch('changeContent')}>