diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7e335295fa..9a8645a5bf 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1691,6 +1691,9 @@ dependencies: openai: specifier: ^4.56.0 version: 4.56.0(zod@3.23.8) + openid-client: + specifier: ~5.7.0 + version: 5.7.0 otp-generator: specifier: ^4.0.1 version: 4.0.1 @@ -17193,6 +17196,10 @@ packages: engines: {node: '>= 0.6.0'} dev: false + /jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + dev: false + /jose@5.6.3: resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} dev: false @@ -19076,6 +19083,11 @@ packages: resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==} dev: false + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -19210,6 +19222,11 @@ packages: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} dev: false + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} dev: false @@ -19298,6 +19315,15 @@ packages: hasBin: true dev: false + /openid-client@5.7.0: + resolution: {integrity: sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==} + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.0.3 + dev: false + /option@0.2.4: resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} dev: false @@ -25220,7 +25246,7 @@ packages: dev: false file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-RDD+zkNosRxJuY+bq2skRGbRPr4saOaAvlkKv3gvvIXT5RxnmOXnznS6haxIcXw4VA96oxv08U9Mu8+3PjgpwQ==, tarball: file:projects/auth-providers.tgz} + resolution: {integrity: sha512-0YbyLxnpaSZfdCfdlt5T9LI/TmahO7fbLohsHvXQVFb2J1InAIyu1WWzbS92CLYLHxQtJmSIOAbPVbRV1wPWbw==, tarball: file:projects/auth-providers.tgz} id: file:projects/auth-providers.tgz name: '@rush-temp/auth-providers' version: 0.0.0 @@ -25248,6 +25274,7 @@ packages: koa-router: 12.0.1 koa-session: 6.4.0 mongodb: 6.9.0 + openid-client: 5.7.0 passport-custom: 1.1.1 passport-github2: 0.1.12 passport-google-oauth20: 2.0.0 diff --git a/models/activity/src/index.ts b/models/activity/src/index.ts index 14d804a652..c14f33b646 100644 --- a/models/activity/src/index.ts +++ b/models/activity/src/index.ts @@ -152,6 +152,9 @@ export class TActivityReference extends TActivityMessage implements ActivityRefe @Prop(TypeMarkup(), activity.string.Message) @Index(IndexKind.FullText) message!: string + + @Prop(TypeTimestamp(), activity.string.Edit) + editedOn?: Timestamp } @Model(activity.class.ActivityInfoMessage, activity.class.ActivityMessage) diff --git a/models/chunter/src/types.ts b/models/chunter/src/types.ts index c24f2e086e..bfe87f2bf9 100644 --- a/models/chunter/src/types.ts +++ b/models/chunter/src/types.ts @@ -22,7 +22,6 @@ import { TypeMarkup, TypeRef, TypeString, - TypeTimestamp, UX } from '@hcengineering/model' import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core' @@ -59,6 +58,7 @@ import type { DocNotifyContext } from '@hcengineering/notification' import chunter from './plugin' export const DOMAIN_CHUNTER = 'chunter' as Domain + @Model(chunter.class.ChunterSpace, core.class.Space) export class TChunterSpace extends TSpace implements ChunterSpace { @Prop(PropCollection(activity.class.ActivityMessage), chunter.string.Messages) @@ -84,9 +84,6 @@ export class TChatMessage extends TActivityMessage implements ChatMessage { @Index(IndexKind.FullText) message!: string - @Prop(TypeTimestamp(), chunter.string.Edit) - editedOn?: Timestamp - @Prop(PropCollection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files }) diff --git a/models/tracker/src/actions.ts b/models/tracker/src/actions.ts index 783895f8c5..e42af938fa 100644 --- a/models/tracker/src/actions.ts +++ b/models/tracker/src/actions.ts @@ -562,6 +562,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId: input: 'focus', category: tracker.category.Tracker, target: tracker.class.Issue, + visibilityTester: view.function.IsClipboardAvailable, context: { mode: ['context', 'browser'], application: tracker.app.Tracker, @@ -582,6 +583,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId: input: 'focus', category: tracker.category.Tracker, target: tracker.class.Issue, + visibilityTester: view.function.IsClipboardAvailable, context: { mode: ['context', 'browser'], application: tracker.app.Tracker, @@ -602,6 +604,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId: input: 'focus', category: tracker.category.Tracker, target: tracker.class.Issue, + visibilityTester: view.function.IsClipboardAvailable, context: { mode: ['context', 'browser'], application: tracker.app.Tracker, diff --git a/models/view/src/plugin.ts b/models/view/src/plugin.ts index c45bfc0e37..aeb975a63d 100644 --- a/models/view/src/plugin.ts +++ b/models/view/src/plugin.ts @@ -140,6 +140,7 @@ export default mergeIds(viewId, view, { CanDeleteSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanJoinSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanLeaveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise>, + IsClipboardAvailable: '' as Resource<(doc?: Doc | Doc[]) => Promise>, BlobImageMetadata: '' as Resource<(file: FileOrBlob, blob: Ref) => Promise>, BlobVideoMetadata: '' as Resource<(file: FileOrBlob, blob: Ref) => Promise> }, diff --git a/packages/theme/styles/components.scss b/packages/theme/styles/components.scss index 142c807643..162151088a 100644 --- a/packages/theme/styles/components.scss +++ b/packages/theme/styles/components.scss @@ -321,6 +321,7 @@ width: 100%; min-width: 0; min-height: var(--spacing-6_5); + overflow: hidden; &.clearPadding > .hulyHeader-row { padding: 0; @@ -577,9 +578,12 @@ } &.second:not(.isOpen), &.border, - &.default:not(.nested:last-child) { + &.default:not(.nested) { border-bottom: 1px solid var(--theme-navpanel-divider); // var(--global-surface-01-BorderColor); } + &.default.nested:not(:last-child) { + border-bottom: 1px dashed var(--theme-navpanel-divider); + } .hulyAccordionItem-header { display: flex; justify-content: space-between; @@ -790,6 +794,12 @@ } } } + &.hiddenHeader { + display: none; + visibility: hidden; + + &.nested + .hulyAccordionItem-content { padding-top: var(--spacing-1); } + } &:hover .hulyAccordionItem-header__chevron { color: var(--button-subtle-IconColor); background-color: var(--global-ui-hover-BackgroundColor); @@ -806,6 +816,9 @@ &.medium.bottomSpace + .hulyAccordionItem-content { padding-bottom: var(--spacing-2); } + &.medium.nested.bottomSpace + .hulyAccordionItem-content { + padding-bottom: var(--spacing-1); + } &.large.bottomSpace + .hulyAccordionItem-content { padding-bottom: var(--spacing-2); } diff --git a/packages/ui/src/components/AccordionItem.svelte b/packages/ui/src/components/AccordionItem.svelte index e2f3b28520..750e0d0d1a 100644 --- a/packages/ui/src/components/AccordionItem.svelte +++ b/packages/ui/src/components/AccordionItem.svelte @@ -39,6 +39,7 @@ export let duration: number | boolean = false export let fixHeader: boolean = false export let categoryHeader: boolean = false + export let hiddenHeader: boolean = false export let background: string | undefined = undefined const dispatch = createEventDispatcher() @@ -74,6 +75,7 @@ class:selectable class:scroller-header={fixHeader} class:categoryHeader + class:hiddenHeader style:background-color={background ?? 'transparent'} on:click|stopPropagation={handleClick} > diff --git a/packages/ui/src/components/Header.svelte b/packages/ui/src/components/Header.svelte index 250b517774..edf9ae97d5 100644 --- a/packages/ui/src/components/Header.svelte +++ b/packages/ui/src/components/Header.svelte @@ -39,6 +39,7 @@ export let noPrint: boolean = false export let freezeBefore: boolean = false export let doubleRowWidth = 768 + export let closeOnEscape: boolean = true const dispatch = createEventDispatcher() @@ -61,7 +62,7 @@ }) function _close (ev: KeyboardEvent): void { - if (closeButton && ev.key === 'Escape') { + if (closeButton && ev.key === 'Escape' && closeOnEscape) { ev.preventDefault() ev.stopPropagation() @@ -138,7 +139,9 @@ {/if} {#if closeButton} {#if type !== 'type-popup'}
{/if} -
Esc
+ {#if closeOnEscape} +
Esc
+ {/if} dispatch('close')} /> {/if}
@@ -233,7 +236,9 @@ {/if} {#if closeButton} {#if type !== 'type-popup'}
{/if} -
Esc
+ {#if closeOnEscape} +
Esc
+ {/if} dispatch('close')} /> {/if} {/if} diff --git a/packages/ui/src/components/ModernTab.svelte b/packages/ui/src/components/ModernTab.svelte index 2be4c7f9cb..90b552fbf1 100644 --- a/packages/ui/src/components/ModernTab.svelte +++ b/packages/ui/src/components/ModernTab.svelte @@ -85,6 +85,7 @@ border-radius: 0.25rem; cursor: pointer; overflow: hidden; + color: var(--theme-content-color); &.primary { background-color: var(--theme-button-pressed); diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 555e14935f..178a4ff5ac 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -126,6 +126,7 @@ export interface TabItem { } export interface BreadcrumbItem { + id?: string icon?: Asset | AnySvelteComponent | ComponentType iconProps?: any iconWidth?: string diff --git a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte index 2b84bf79ac..9578c9f193 100644 --- a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte +++ b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte @@ -26,6 +26,7 @@ import { getActions, restrictionStore, showMenu } from '@hcengineering/view-resources' import { Asset } from '@hcengineering/platform' import { Action as ViewAction } from '@hcengineering/view' + import notification from '@hcengineering/notification' import ReactionsPresenter from '../reactions/ReactionsPresenter.svelte' import ActivityMessagePresenter from './ActivityMessagePresenter.svelte' @@ -228,6 +229,9 @@ + {#if message.editedOn} + ( + {/if} {#if withActions && inlineActions.length > 0 && !readonly}
diff --git a/plugins/activity/src/index.ts b/plugins/activity/src/index.ts index bc087b7bb3..b0b18aa027 100644 --- a/plugins/activity/src/index.ts +++ b/plugins/activity/src/index.ts @@ -46,6 +46,7 @@ export interface ActivityMessage extends AttachedDoc { replies?: number reactions?: number + editedOn?: Timestamp } export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage @@ -81,6 +82,7 @@ export interface ActivityInfoMessage extends ActivityMessage { props?: Record icon?: Asset iconProps?: Record + editedOn?: Timestamp // A possible set of links to some platform resources. links?: { _class: Ref>, _id: Ref }[] diff --git a/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte b/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte index a00fbb52d4..da02fe2841 100644 --- a/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte +++ b/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte @@ -42,6 +42,7 @@ hideExtra={false} adaptive="autoExtra" doubleRowWidth={350} + closeOnEscape={false} on:close >
diff --git a/plugins/chunter-resources/src/components/Channel.svelte b/plugins/chunter-resources/src/components/Channel.svelte index cf9f74ac77..eb43c295af 100644 --- a/plugins/chunter-resources/src/components/Channel.svelte +++ b/plugins/chunter-resources/src/components/Channel.svelte @@ -30,6 +30,7 @@ export let filters: Ref[] = [] export let isAsideOpened = false export let syncLocation = true + export let freeze = false const client = getClient() const hierarchy = client.getHierarchy() @@ -111,5 +112,6 @@ provider={dataProvider} {isAsideOpened} loadMoreAllowed={!isDocChannel} + {freeze} /> {/if} diff --git a/plugins/chunter-resources/src/components/ChannelHeader.svelte b/plugins/chunter-resources/src/components/ChannelHeader.svelte index 37fd340167..284153a4eb 100644 --- a/plugins/chunter-resources/src/components/ChannelHeader.svelte +++ b/plugins/chunter-resources/src/components/ChannelHeader.svelte @@ -36,6 +36,7 @@ export let isAsideShown: boolean = false export let filters: Ref[] = [] export let canOpenInSidebar: boolean = false + export let closeOnEscape: boolean = true const client = getClient() const hierarchy = client.getHierarchy() @@ -80,6 +81,7 @@ {isAsideShown} {withSearch} {canOpenInSidebar} + {closeOnEscape} on:aside-toggled on:close > diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index c41d057f07..5dffbc7151 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -61,8 +61,9 @@ export let skipLabels = false export let loadMoreAllowed = true export let isAsideOpened = false - export let initialScrollBottom = true export let fullHeight = true + export let fixedInput = true + export let freeze = false const doc = object @@ -130,17 +131,21 @@ } }) + function isFreeze (): boolean { + return freeze + } + $: displayMessages = filterChatMessages(messages, filters, filterResources, doc._class, selectedFilters) const unsubscribe = inboxClient.inboxNotificationsByContext.subscribe(() => { - if (notifyContext !== undefined) { + if (notifyContext !== undefined && !isFreeze()) { recheckNotifications(notifyContext) readViewportMessages() } }) function scrollToBottom (afterScrollFn?: () => void): void { - if (scroller != null && scrollElement != null) { + if (scroller != null && scrollElement != null && !isFreeze()) { scroller.scrollBy(scrollElement.scrollHeight) updateSelectedDate() afterScrollFn?.() @@ -338,7 +343,7 @@ let messagesToReadAccumulatorTimer: any function readViewportMessages (): void { - if (!scrollElement || !scrollContentBox) { + if (!scrollElement || !scrollContentBox || isFreeze()) { return } @@ -452,22 +457,14 @@ isInitialScrolling = false } else if (separatorIndex === -1) { await wait() - if (initialScrollBottom) { - isScrollInitialized = true - shouldWaitAndRead = true - autoscroll = true - shouldScrollToNew = true - isInitialScrolling = false - waitLastMessageRenderAndRead(() => { - autoscroll = false - }) - } else { - isScrollInitialized = true + isScrollInitialized = true + shouldWaitAndRead = true + autoscroll = true + shouldScrollToNew = true + isInitialScrolling = false + waitLastMessageRenderAndRead(() => { autoscroll = false - updateShouldScrollToNew() - isInitialScrolling = false - readViewportMessages() - } + }) } else if (separatorElement) { await wait() scrollToSeparator() @@ -519,6 +516,7 @@ function scrollToNewMessages (): void { if (!scrollElement || !shouldScrollToNew) { + readViewportMessages() return } @@ -557,14 +555,22 @@ return } + if (isFreeze()) { + messagesCount = newCount + return + } + if (scrollToRestore > 0) { void restoreScroll() } else if (dateToJump !== undefined) { await wait() scrollToDate(dateToJump) - } else if (messagesCount > 0 && newCount > messagesCount) { + } else if (shouldScrollToNew && messagesCount > 0 && newCount > messagesCount) { await wait() scrollToNewMessages() + } else { + await wait() + readViewportMessages() } messagesCount = newCount @@ -576,7 +582,7 @@ return } - if (shouldScrollToNew && initialScrollBottom) { + if (shouldScrollToNew) { scrollToBottom() } @@ -660,7 +666,7 @@ } else if (element != null) { const { scrollHeight, scrollTop, offsetHeight } = element - showScrollDownButton = scrollHeight > offsetHeight + scrollTop + 300 + showScrollDownButton = scrollHeight > offsetHeight + scrollTop + 50 } else { showScrollDownButton = false } @@ -691,7 +697,7 @@ $: void forceReadContext(isScrollAtBottom, notifyContext) async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise { - if (context === undefined || !isScrollAtBottom || forceRead) return + if (context === undefined || !isScrollAtBottom || forceRead || isFreeze()) return const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context if (lastViewedTimestamp >= lastUpdateTimestamp) return @@ -708,6 +714,10 @@ } const canLoadNextForwardStore = provider.canLoadNextForwardStore + + $: if (!freeze) { + readViewportMessages() + } {#if isLoading} @@ -777,6 +787,16 @@ /> {/each} + {#if !fixedInput} +
+ +
+ {/if} + {#if loadMoreAllowed && $canLoadNextForwardStore} {/if} @@ -794,7 +814,7 @@
{/if}
- {#if object} + {#if fixedInput && object}
-{#if object} +{#if object && renderChannel}
{#key object._id} - + {/key}
{/if} {#if threadId}
- closeThreadInSidebarChannel(widget, tab)} /> + closeThreadInSidebarChannel(widget, tab)} on:close />
{/if} diff --git a/plugins/chunter-resources/src/components/ChannelView.svelte b/plugins/chunter-resources/src/components/ChannelView.svelte index ef849cd3fc..d0e9c5888f 100644 --- a/plugins/chunter-resources/src/components/ChannelView.svelte +++ b/plugins/chunter-resources/src/components/ChannelView.svelte @@ -93,7 +93,7 @@ } -
+
-{#if widget && tab && tab.type === 'channel'} +{#if widget && tab} -{:else if widget && tab && tab.type === 'thread'} - { - handleClose(tab?.id) - }} - /> {/if} diff --git a/plugins/chunter-resources/src/components/ChatWidgetTab.svelte b/plugins/chunter-resources/src/components/ChatWidgetTab.svelte index ffc126d5e6..5bfc57a6db 100644 --- a/plugins/chunter-resources/src/components/ChatWidgetTab.svelte +++ b/plugins/chunter-resources/src/components/ChatWidgetTab.svelte @@ -50,7 +50,7 @@ let count: number = 0 - $: objectId = tab.type === 'thread' ? tab.data.thread : tab.data._id + $: objectId = tab.data.thread ?? tab.data._id $: context = objectId ? $contextByDocStore.get(objectId) : undefined const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => { diff --git a/plugins/chunter-resources/src/components/Header.svelte b/plugins/chunter-resources/src/components/Header.svelte index 89a1a3b412..6920564630 100644 --- a/plugins/chunter-resources/src/components/Header.svelte +++ b/plugins/chunter-resources/src/components/Header.svelte @@ -57,6 +57,7 @@ export let adaptive: HeaderAdaptive = 'default' export let hideActions: boolean = false export let canOpenInSidebar: boolean = false + export let closeOnEscape: boolean = true const client = getClient() const dispatch = createEventDispatcher() @@ -72,6 +73,7 @@ hideActions={!((canOpen && object) || withAside || $$slots.actions) || hideActions} hideDescription={!description} adaptive={adaptive !== 'default' ? adaptive : withFilters ? 'freezeActions' : 'disabled'} + {closeOnEscape} on:click on:close > diff --git a/plugins/chunter-resources/src/components/JumpToDateSelector.svelte b/plugins/chunter-resources/src/components/JumpToDateSelector.svelte index 4d7e760922..7630810893 100644 --- a/plugins/chunter-resources/src/components/JumpToDateSelector.svelte +++ b/plugins/chunter-resources/src/components/JumpToDateSelector.svelte @@ -33,7 +33,7 @@
{ showPopup(DateRangePopup, {}, div, (v) => { if (v) { @@ -68,13 +68,17 @@ left: 0; width: 100%; height: 1px; - background-color: var(--theme-divider-color); + background-color: var(--highlight-select-border); } + .dateSelectorButton { + cursor: pointer; padding: 0.25rem 0.5rem; height: max-content; - background-color: var(--theme-list-row-color); - border: 1px solid var(--theme-divider-color); + color: var(--theme-content-color); + background-color: var(--highlight-select); + border: 1px solid var(--highlight-select-border); + font-weight: 500; z-index: 10; } } diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte index 2c649a9a3a..4c82e87122 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte @@ -14,20 +14,15 @@ --> {#if label} {/if} -{#if editedOn} - -{/if} diff --git a/plugins/workbench-resources/src/sidebar.ts b/plugins/workbench-resources/src/sidebar.ts index 50a4f6ad8f..8bf9a40e1b 100644 --- a/plugins/workbench-resources/src/sidebar.ts +++ b/plugins/workbench-resources/src/sidebar.ts @@ -101,7 +101,12 @@ export function openWidget (widget: Widget, data?: Record, active = const { widgetsState } = state const widgetState = widgetsState.get(widget._id) - widgetsState.set(widget._id, { _id: widget._id, data, tab: widgetState?.tab, tabs: widgetState?.tabs ?? [] }) + widgetsState.set(widget._id, { + _id: widget._id, + data: data ?? widgetState?.data, + tab: widgetState?.tab, + tabs: widgetState?.tabs ?? [] + }) sidebarStore.set({ ...state, diff --git a/pods/authProviders/package.json b/pods/authProviders/package.json index 4735797719..20e8c75f2b 100644 --- a/pods/authProviders/package.json +++ b/pods/authProviders/package.json @@ -52,6 +52,7 @@ "passport-custom": "~1.1.1", "passport-google-oauth20": "~2.0.0", "passport-github2": "~0.1.12", + "openid-client": "~5.7.0", "koa-passport": "^6.0.0", "koa": "^2.15.3", "koa-router": "^12.0.1", diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 57ffae31db..6fc525cd5f 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -12,7 +12,7 @@ export function registerGithub ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -69,6 +69,7 @@ export function registerGithub ( let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const db = await dbPromise if (state.inviteId != null && state.inviteId !== '') { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { githubId: ctx.state.user.id diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index 6453165d58..be68fe269a 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -12,7 +12,7 @@ export function registerGoogle ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -74,6 +74,7 @@ export function registerGoogle ( let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const db = await dbPromise if (state.inviteId != null && state.inviteId !== '') { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any) } else { diff --git a/pods/authProviders/src/index.ts b/pods/authProviders/src/index.ts index 0ab49993c0..a812761f03 100644 --- a/pods/authProviders/src/index.ts +++ b/pods/authProviders/src/index.ts @@ -5,6 +5,7 @@ import session from 'koa-session' import { Db } from 'mongodb' import { registerGithub } from './github' import { registerGoogle } from './google' +import { registerOpenid } from './openid' import { registerToken } from './token' import { BrandingMap, MeasureContext } from '@hcengineering/core' @@ -15,7 +16,7 @@ export type AuthProvider = ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + db: Promise, frontUrl: string, brandings: BrandingMap ) => string | undefined @@ -24,7 +25,7 @@ export function registerProviders ( ctx: MeasureContext, app: Koa, router: Router, - db: Db, + db: Promise, serverSecret: string, frontUrl: string | undefined, brandings: BrandingMap @@ -60,7 +61,7 @@ export function registerProviders ( registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings) const res: string[] = [] - const providers: AuthProvider[] = [registerGoogle, registerGithub] + const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid] for (const provider of providers) { const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings) if (value !== undefined) res.push(value) diff --git a/pods/authProviders/src/openid.ts b/pods/authProviders/src/openid.ts new file mode 100644 index 0000000000..b517c28664 --- /dev/null +++ b/pods/authProviders/src/openid.ts @@ -0,0 +1,119 @@ +// +// Copyright © 2024 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 f. +// +import { joinWithProvider, loginWithProvider, type LoginInfo } from '@hcengineering/account' +import { BrandingMap, concatLink, MeasureContext, getBranding } from '@hcengineering/core' +import Router from 'koa-router' +import { Db } from 'mongodb' +import { Issuer, Strategy } from 'openid-client' +import qs from 'querystringify' + +import { Passport } from '.' +import { getHost, safeParseAuthState } from './utils' + +export function registerOpenid ( + measureCtx: MeasureContext, + passport: Passport, + router: Router, + accountsUrl: string, + dbPromise: Promise, + frontUrl: string, + brandings: BrandingMap +): string | undefined { + const openidClientId = process.env.OPENID_CLIENT_ID + const openidClientSecret = process.env.OPENID_CLIENT_SECRET + const issuer = process.env.OPENID_ISSUER + + const redirectURL = '/auth/openid/callback' + if (openidClientId === undefined || openidClientSecret === undefined || issuer === undefined) return + + void Issuer.discover(issuer).then((issuerObj) => { + const client = new issuerObj.Client({ + client_id: openidClientId, + client_secret: openidClientSecret, + redirect_uris: [concatLink(accountsUrl, redirectURL)], + response_types: ['code'] + }) + + passport.use( + 'oidc', + new Strategy({ client, passReqToCallback: true }, (req: any, tokenSet: any, userinfo: any, done: any) => { + return done(null, userinfo) + }) + ) + }) + + router.get('/auth/openid', async (ctx, next) => { + measureCtx.info('try auth via', { provider: 'openid' }) + const host = getHost(ctx.request.headers) + const brandingKey = host !== undefined ? brandings[host]?.key ?? undefined : undefined + const state = encodeURIComponent( + JSON.stringify({ + inviteId: ctx.query?.inviteId, + branding: brandingKey + }) + ) + + await passport.authenticate('oidc', { + scope: 'openid profile email', + state + })(ctx, next) + }) + + router.get( + redirectURL, + async (ctx, next) => { + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + + await passport.authenticate('oidc', { + failureRedirect: concatLink(branding?.front ?? frontUrl, '/login') + })(ctx, next) + }, + async (ctx, next) => { + try { + const email = ctx.state.user.email ?? `openid:${ctx.state.user.sub}` + const [first, last] = ctx.state.user.name?.split(' ') ?? [ctx.state.user.username, ''] + measureCtx.info('Provider auth handler', { email, type: 'openid' }) + if (email !== undefined) { + let loginInfo: LoginInfo + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + const db = await dbPromise + if (state.inviteId != null && state.inviteId !== '') { + loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { + openId: ctx.state.user.sub + }) + } else { + loginInfo = await loginWithProvider(measureCtx, db, null, email, first, last, { + openId: ctx.state.user.sub + }) + } + + const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) + + measureCtx.info('Success auth, redirect', { email, type: 'openid', target: origin }) + // Successful authentication, redirect to your application + ctx.redirect(`${origin}?${query}`) + } + } catch (err: any) { + measureCtx.error('failed to auth', { err, type: 'openid', user: ctx.state?.user }) + } + await next() + } + ) + + return 'openid' +} diff --git a/pods/authProviders/src/token.ts b/pods/authProviders/src/token.ts index fa2ad0793f..8edbd7d32b 100644 --- a/pods/authProviders/src/token.ts +++ b/pods/authProviders/src/token.ts @@ -12,7 +12,7 @@ export function registerToken ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -21,9 +21,11 @@ export function registerToken ( new CustomStrategy(function (req: any, done: any) { const token = req.body.token ?? req.query.token - getAccountInfoByToken(measureCtx, db, null, token) - .then((user: any) => done(null, user)) - .catch((err: any) => done(err)) + void dbPromise.then((db) => { + getAccountInfoByToken(measureCtx, db, null, token) + .then((user: any) => done(null, user)) + .catch((err: any) => done(err)) + }) }) ) diff --git a/qms-tests/sanity/tests/model/documents/document-comments-page.ts b/qms-tests/sanity/tests/model/documents/document-comments-page.ts index b792ff6495..e63a23a385 100644 --- a/qms-tests/sanity/tests/model/documents/document-comments-page.ts +++ b/qms-tests/sanity/tests/model/documents/document-comments-page.ts @@ -76,15 +76,15 @@ export class DocumentCommentsPage extends DocumentCommonPage { reply: string ): Promise { const comment = this.page - .locator('div.popup div.root div.header > div > span:first-child', { hasText: String(commentId) }) + .locator('div.text-editor-popup span[data-id="commentId"]', { hasText: `#${String(commentId)}` }) .locator('xpath=../../../..') // check header - await expect(comment.locator('div.root > div.header > div:first-child')).toContainText(header) + await expect(comment.locator('div.root > div.header > span.overflow-label')).toContainText(header) // can be resolved await comment.locator('div.header div.tools button').hover() await expect(comment.locator('div.header div.tools button')).toBeEnabled() // check author - await expect(comment.locator('div.root div.header > div.username span.ap-label').first()).toHaveText(author) + await expect(comment.locator('div.activityMessage div.header a span[class*="label"]').first()).toHaveText(author) // check message await expect( comment.locator('div.activityMessage div.flex-col div.clear-mins div.text-markup-view > p').first() @@ -103,15 +103,15 @@ export class DocumentCommentsPage extends DocumentCommonPage { reply: string ): Promise { const comment = this.page - .locator('div.box div.root div.header > div > span:first-child', { hasText: String(commentId) }) + .locator('div[data-testid="comment"] span[data-id="commentId"]', { hasText: `#${String(commentId)}` }) .locator('xpath=../../../..') // check header - await expect(comment.locator('div.root > div.header > div:first-child')).toContainText(header) + await expect(comment.locator('div.root > div.header > span.overflow-label')).toContainText(header) // can be resolved - await comment.locator('div.header > div > span:last-child').hover() + await comment.locator('div.root > div.header > span.overflow-label').first().hover() await expect(comment.locator('div.header div.tools button')).toBeEnabled() // check author - await expect(comment.locator('div.root div.header > div.username span.ap-label').first()).toHaveText(author) + await expect(comment.locator('div.activityMessage div.header a span[class*="label"]').first()).toHaveText(author) // check message await expect( comment.locator('div.activityMessage div.flex-col div.clear-mins div.text-markup-view > p').first() diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index c57ccaf2a6..03257a6f06 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -103,10 +103,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap ) app.use(bodyParser()) - void client.getClient().then(async (p: MongoClient) => { - const db = p.db(ACCOUNT_DB) - registerProviders(measureCtx, app, router, db, serverSecret, frontURL, brandings) + const mongoClientPromise = client.getClient() + const dbPromise = mongoClientPromise.then((c) => c.db(ACCOUNT_DB)) + registerProviders(measureCtx, app, router, dbPromise, serverSecret, frontURL, brandings) + void dbPromise.then((db) => { setInterval( () => { void cleanExpiredOtp(db) diff --git a/tests/sanity/tests/documents/documents-content.spec.ts b/tests/sanity/tests/documents/documents-content.spec.ts index 8981ca0d1c..6b5bf05eba 100644 --- a/tests/sanity/tests/documents/documents-content.spec.ts +++ b/tests/sanity/tests/documents/documents-content.spec.ts @@ -36,7 +36,7 @@ test.describe('Content in the Documents tests', () => { let documentsSecondPage: DocumentsPage let documentContentSecondPage: DocumentContentPage - test.beforeEach(async ({ browser, page, request }) => { + test.beforeEach(async ({ page, request }) => { leftSideMenuPage = new LeftSideMenuPage(page) documentsPage = new DocumentsPage(page) documentContentPage = new DocumentContentPage(page) @@ -116,8 +116,8 @@ test.describe('Content in the Documents tests', () => { await documentContentPage.inputContentParapraph().click() await documentContentPage.leftMenu().click() - await documentContentPage.menuPopupItemButton('Table').click() - await documentContentPage.menuPopupItemButton('1x2').first().click() + await documentContentPage.clickPopupItem('Table') + await documentContentPage.clickPopupItem('1x2') await documentContentPage.proseTableCell(0, 0).fill('One') await documentContentPage.proseTableCell(0, 1).fill('Two') await documentContentPage.buttonInsertColumn().click() @@ -295,4 +295,64 @@ test.describe('Content in the Documents tests', () => { await documentContentPage.page.keyboard.press('Escape') }) }) + + test('Checking styles in a Document', async ({ page, browser, request }) => { + const content: string = [...new Array(20).keys()].map((index) => `Line ${index + 1}`).join('\n') + const testLink: string = 'http://test/link/123456' + const testNote: string = 'Test Note' + + await documentContentPage.addContentToTheNewLine(content, false) + await documentContentPage.applyToolbarCommand('Line 1', 'btnH1') + await documentContentPage.applyToolbarCommand('Line 2', 'btnH2') + await documentContentPage.applyToolbarCommand('Line 3', 'btnH3') + await documentContentPage.goToByTOC('Line 3') + await documentContentPage.goToByTOC('Line 1') + + await documentContentPage.applyToolbarCommand('Line 4', 'btnBold') + await documentContentPage.applyToolbarCommand('Line 5', 'btnItalic') + await documentContentPage.applyToolbarCommand('Line 6', 'btnStrikethrough') + await documentContentPage.applyToolbarCommand('Line 7', 'btnUnderlined') + + await documentContentPage.applyToolbarCommand('Line 8', 'btnLink') + await documentContentPage.inputFormLink().fill(testLink) + await documentContentPage.buttonFormLinkSave().click() + + await documentContentPage.addSeparator('Line 9') + for (let i = 9; i <= 11; i++) { + await documentContentPage.applyToolbarCommand(`Line ${i}`, 'btnOrderedList') + } + await documentContentPage.addSeparator('Line 12') + for (let i = 12; i <= 14; i++) { + await documentContentPage.applyToolbarCommand(`Line ${i}`, 'btnBulletedList') + } + await documentContentPage.addSeparator('Line 15') + await documentContentPage.applyToolbarCommand('Line 15', 'btnH3') + await documentContentPage.goToByTOC('Line 15') + + await documentContentPage.applyToolbarCommand('Line 16', 'btnBlockquote') + await documentContentPage.applyToolbarCommand('Line 17', 'btnCode') + await documentContentPage.applyToolbarCommand('Line 18', 'btnCodeBlock') + await documentContentPage.changeCodeBlockLanguage('Line 18', 'auto', 'css') + await documentContentPage.applyNote('Line 19', 'warning', testNote) + await documentContentPage.addImage('Line 20') + await page.keyboard.type('Cat') + + newUser2 = generateUser() + await createAccount(request, newUser2) + const linkText = await getInviteLink(page) + using _secondPage = await getSecondPageByInvite(browser, linkText, newUser2) + secondPage = _secondPage.page + leftSideMenuSecondPage = new LeftSideMenuPage(secondPage) + documentsSecondPage = new DocumentsPage(secondPage) + documentContentSecondPage = new DocumentContentPage(secondPage) + + await leftSideMenuSecondPage.clickDocuments() + await documentsSecondPage.openTeamspace(testDocument.space) + await documentsSecondPage.openDocument(testDocument.title) + await documentContentSecondPage.checkDocumentTitle(testDocument.title) + await documentContentSecondPage.checkLinkInTheText('Line 8', testLink) + await documentContentSecondPage.goToByTOC('Line 15') + await documentContentSecondPage.checkImage() + await documentContentSecondPage.checkNote('Line 19', 'warning', testNote) + }) }) diff --git a/tests/sanity/tests/documents/documents.spec.ts b/tests/sanity/tests/documents/documents.spec.ts index d8d837c391..46e45714a6 100644 --- a/tests/sanity/tests/documents/documents.spec.ts +++ b/tests/sanity/tests/documents/documents.spec.ts @@ -169,7 +169,7 @@ test.describe('Documents tests', () => { }) }) - test('Add Link to the Document', async () => { + test.skip('Add Link to the Document', async () => { const contentLink = 'Lineforthelink' const linkDocument: NewDocument = { title: `Links Document Title-${generateId()}`, diff --git a/tests/sanity/tests/model/common-page.ts b/tests/sanity/tests/model/common-page.ts index f04db77642..ea36065161 100644 --- a/tests/sanity/tests/model/common-page.ts +++ b/tests/sanity/tests/model/common-page.ts @@ -206,6 +206,10 @@ export class CommonPage { await expect(this.menuPopupItemButton(itemText)).toBeVisible() } + async clickPopupItem (itemText: string): Promise { + await this.menuPopupItemButton(itemText).first().click() + } + async selectFilter (filter: string, filterSecondLevel?: string): Promise { await this.buttonFilter().click() await this.selectPopupMenu(filter).click() diff --git a/tests/sanity/tests/model/documents/document-content-page.ts b/tests/sanity/tests/model/documents/document-content-page.ts index df91df49bf..f5ec05bb84 100644 --- a/tests/sanity/tests/model/documents/document-content-page.ts +++ b/tests/sanity/tests/model/documents/document-content-page.ts @@ -1,6 +1,7 @@ import { type Locator, type Page, expect } from '@playwright/test' import { CommonPage } from '../common-page' import { uploadFile } from '../../utils' +import path from 'path' export class DocumentContentPage extends CommonPage { readonly page: Page @@ -35,7 +36,9 @@ export class DocumentContentPage extends CommonPage { readonly buttonInsertInnerRow = (row: number = 0): Locator => this.page.locator('table.proseTable').locator('tr').nth(row).locator('div.table-row-insert button') - readonly buttonToolbarLink = (): Locator => this.page.locator('div.text-editor-toolbar button[data-id="btnLink"]') + readonly buttonOnToolbar = (id: string): Locator => + this.page.locator(`div.text-editor-toolbar button[data-id="${id}"]`) + readonly inputFormLink = (): Locator => this.page.locator('form[id="text-editor:string:Link"] input') readonly buttonFormLinkSave = (): Locator => this.page.locator('form[id="text-editor:string:Link"] button[type="submit"]') @@ -61,6 +64,18 @@ export class DocumentContentPage extends CommonPage { readonly slashActionItemsPopup = (): Locator => this.page.locator('.selectPopup') + readonly codeBlock = (hasText: string): Locator => this.page.locator('pre.proseCodeBlock > code', { hasText }) + readonly inputFormNote = (): Locator => this.page.locator('form[id="text-editor:string:ConfigureNote"] textarea') + readonly colorFormNote = (color: string): Locator => + this.page.locator(`form[id="text-editor:string:ConfigureNote"] div.colorBox.${color}`) + + readonly setFormNote = (): Locator => + this.page.locator('form[id="text-editor:string:ConfigureNote"] div.antiCard-footer button[type="submit"]') + + readonly inputImageFile = (): Locator => this.page.locator('input[id="imageInput"]') + readonly imageInContent = (): Locator => this.page.locator('p img[data-type="image"]') + readonly noteInContent = (hasText: string): Locator => this.page.locator('p span[data-mark="note"]', { hasText }) + async checkDocumentTitle (title: string): Promise { await expect(this.buttonDocumentTitle()).toHaveValue(title) } @@ -69,10 +84,10 @@ export class DocumentContentPage extends CommonPage { await expect(this.buttonLockedInTitle()).toBeVisible({ timeout: 1000 }) } - async addContentToTheNewLine (newContent: string): Promise { + async addContentToTheNewLine (newContent: string, newLine: boolean = true): Promise { await expect(this.inputContent()).toBeVisible() await expect(this.inputContent()).toHaveJSProperty('contentEditable', 'true') - await this.inputContent().pressSequentially(`\n${newContent}`) + await this.inputContent().pressSequentially(`${newLine ? '\n' : ''}${newContent}`) const endContent = await this.inputContent().textContent() return endContent ?? '' } @@ -202,14 +217,70 @@ export class DocumentContentPage extends CommonPage { async addLinkToText (text: string, link: string): Promise { await expect(this.page.locator('p', { hasText: text })).toBeVisible() - await this.page.locator('p', { hasText: text }).click() - await this.page.locator('p', { hasText: text }).dblclick() - await this.buttonToolbarLink().click() + await this.page.locator('p', { hasText: text }).click({ clickCount: 3 }) + await this.buttonOnToolbar('btnLink').click() await this.inputFormLink().fill(link) await this.buttonFormLinkSave().click() } + async clickButtonOnTooltip (id: string): Promise { + await this.buttonOnToolbar(id).click() + } + + async selectLine (text: string): Promise { + const loc: Locator = this.page.locator('p', { hasText: text }).first() + await expect(loc).toBeVisible() + await loc.click({ clickCount: 3 }) + } + + async applyToolbarCommand (text: string, btnId: string): Promise { + await this.selectLine(text) + await this.clickButtonOnTooltip(btnId) + } + + async addSeparator (text: string): Promise { + await this.selectLine(text) + await this.clickLeftMenu() + await this.clickPopupItem('Separator line') + } + + async goToByTOC (text: string): Promise { + await this.tocItems().first().click() + await this.clickPopupItem(text) + } + + async clickLeftMenu (): Promise { + await this.leftMenu().click() + } + + async changeCodeBlockLanguage (text: string, oldLang: string, lang: string): Promise { + await this.codeBlock(text).locator('button.antiButton', { hasText: oldLang }).nth(1).click() + await this.selectMenuItem(this.page, lang, true) + } + + async applyNote (text: string, color: string, note: string): Promise { + await this.applyToolbarCommand(text, 'btnNote') + await this.inputFormNote().fill(note) + await this.colorFormNote(color).click() + await this.setFormNote().click() + } + + async checkNote (text: string, color: string, note: string): Promise { + await expect(this.noteInContent(text)).toBeVisible() + await expect(this.noteInContent(text)).toHaveAttribute('data-kind', color) + await expect(this.noteInContent(text)).toHaveAttribute('title', note) + } + + async addImage (text: string): Promise { + await this.selectLine(text) + await this.inputImageFile().setInputFiles(path.join(__dirname, '../../files/cat.jpeg')) + } + + async checkImage (width: number = 215): Promise { + await expect(this.imageInContent()).toHaveAttribute('width', width.toString()) + } + async checkLinkInTheText (text: string, link: string): Promise { await expect(this.page.locator('a', { hasText: text })).toHaveAttribute('href', link) } diff --git a/tests/sanity/tests/model/recruiting/talent-details-page.ts b/tests/sanity/tests/model/recruiting/talent-details-page.ts index 64cef5795a..a71e0918dd 100644 --- a/tests/sanity/tests/model/recruiting/talent-details-page.ts +++ b/tests/sanity/tests/model/recruiting/talent-details-page.ts @@ -59,6 +59,10 @@ export class TalentDetailsPage extends CommonRecruitingPage { await this.buttonFinalContact().click() await this.selectMenuItem(this.page, talentName.finalContactName) + await expect( + this.buttonMergeRow().locator('div.flex-center', { hasText: talentName.name }).locator('label.checkbox-container') + ).toBeVisible() + await this.buttonMergeRow() .locator('div.flex-center', { hasText: talentName.name }) .locator('label.checkbox-container')