diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index 4c4fad48ac..b2dc6af6d6 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -391,10 +391,27 @@ export function createModel (builder: Builder): void { _class: contact.mixin.Employee, icon: contact.icon.Person, label: contact.string.Employee, + baseQuery: { + role: { $ne: 'GUEST' } + }, createLabel: contact.string.CreateEmployee, createComponent: contact.component.CreateEmployee } }, + { + id: 'guests', + component: workbench.component.SpecialView, + icon: contact.icon.Person, + label: contact.string.Guest, + componentProps: { + _class: contact.mixin.Employee, + icon: contact.icon.Person, + label: contact.string.Guest, + baseQuery: { + role: 'GUEST' + } + } + }, { id: 'persons', component: workbench.component.SpecialView, diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index a6986f578e..f0220b81c6 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -320,7 +320,8 @@ input.search { .flex-presenter, .inline-presenter { flex-wrap: nowrap; min-width: 0; - cursor: pointer; + + &:not(.no-pointer) { cursor: pointer; } .icon { color: var(--theme-dark-color); diff --git a/packages/theme/styles/components.scss b/packages/theme/styles/components.scss index 0d5e1f34fc..84624d3e26 100644 --- a/packages/theme/styles/components.scss +++ b/packages/theme/styles/components.scss @@ -128,18 +128,7 @@ flex-shrink: 0; aspect-ratio: 1; background-color: var(--theme-button-default); - pointer-events: none; - - &.clickable { - cursor: pointer; - pointer-events: auto; - - & > * { - pointer-events: none; - user-select: none; - -webkit-user-drag: none; - } - } + // pointer-events: none; &.withStatus { mask-repeat: no-repeat; @@ -211,6 +200,12 @@ transform: translate(-50%, -50%); } } + + & > * { + pointer-events: none; + user-select: none; + -webkit-user-drag: none; + } } /* Avatar sizes */ diff --git a/plugins/contact-assets/lang/cs.json b/plugins/contact-assets/lang/cs.json index b5a64f7e54..186423a94a 100644 --- a/plugins/contact-assets/lang/cs.json +++ b/plugins/contact-assets/lang/cs.json @@ -115,6 +115,7 @@ "Everyone": "Všichni", "Here": "Zde", "EveryoneDescription": "Upozornit všechny v tomto {title}", - "HereDescription": "Upozornit všechny v tomto {title}" + "HereDescription": "Upozornit všechny v tomto {title}", + "Guest": "Host" } } diff --git a/plugins/contact-assets/lang/de.json b/plugins/contact-assets/lang/de.json index 426387502c..6f593eb0bd 100644 --- a/plugins/contact-assets/lang/de.json +++ b/plugins/contact-assets/lang/de.json @@ -115,6 +115,7 @@ "Everyone": "Jeder", "Here": "Hier", "EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}", - "HereDescription": "Benachrichtigen Sie alle in diesem {title}" + "HereDescription": "Benachrichtigen Sie alle in diesem {title}", + "Guest": "Gast" } } diff --git a/plugins/contact-assets/lang/en.json b/plugins/contact-assets/lang/en.json index 805a85c281..4ef79dfcf9 100644 --- a/plugins/contact-assets/lang/en.json +++ b/plugins/contact-assets/lang/en.json @@ -119,6 +119,7 @@ "Everyone": "Everyone", "Here": "Here", "EveryoneDescription": "Notify everyone in this {title}", - "HereDescription": "Notify every online member in this {title}" + "HereDescription": "Notify every online member in this {title}", + "Guest": "Guest" } } diff --git a/plugins/contact-assets/lang/es.json b/plugins/contact-assets/lang/es.json index e7b28af657..833abbf4a1 100644 --- a/plugins/contact-assets/lang/es.json +++ b/plugins/contact-assets/lang/es.json @@ -119,6 +119,7 @@ "Everyone": "Todos", "Here": "Aquí", "EveryoneDescription": "Notificar a todos en este {title}", - "HereDescription": "Notificar a todos en este {title}" + "HereDescription": "Notificar a todos en este {title}", + "Guest": "Invitado" } } diff --git a/plugins/contact-assets/lang/fr.json b/plugins/contact-assets/lang/fr.json index 60f5bd2af9..7073e20815 100644 --- a/plugins/contact-assets/lang/fr.json +++ b/plugins/contact-assets/lang/fr.json @@ -119,6 +119,7 @@ "Everyone": "Tout le monde", "Here": "Ici", "EveryoneDescription": "Notifier tout le monde dans ce {title}", - "HereDescription": "Notifier tout le monde dans ce {title}" + "HereDescription": "Notifier tout le monde dans ce {title}", + "Guest": "Invité" } } diff --git a/plugins/contact-assets/lang/it.json b/plugins/contact-assets/lang/it.json index dfa097e478..edc3ddc2ac 100644 --- a/plugins/contact-assets/lang/it.json +++ b/plugins/contact-assets/lang/it.json @@ -119,6 +119,7 @@ "Everyone": "Tutti", "Here": "Qui", "EveryoneDescription": "Notifica tutti in questo {title}", - "HereDescription": "Notifica tutti in questo {title}" + "HereDescription": "Notifica tutti in questo {title}", + "Guest": "Ospite" } } diff --git a/plugins/contact-assets/lang/ja.json b/plugins/contact-assets/lang/ja.json index d35d4c809b..334bd6d1de 100644 --- a/plugins/contact-assets/lang/ja.json +++ b/plugins/contact-assets/lang/ja.json @@ -119,6 +119,7 @@ "Everyone": "全員", "Here": "ここ", "EveryoneDescription": "この{title}のすべてに通知する", - "HereDescription": "この{title}のすべてに通知する" + "HereDescription": "この{title}のすべてに通知する", + "Guest": "ゲスト" } } \ No newline at end of file diff --git a/plugins/contact-assets/lang/pt.json b/plugins/contact-assets/lang/pt.json index 056c9a042e..079ec60abc 100644 --- a/plugins/contact-assets/lang/pt.json +++ b/plugins/contact-assets/lang/pt.json @@ -119,6 +119,7 @@ "Everyone": "Todos", "Here": "Aqui", "EveryoneDescription": "Notificar todos neste {title}", - "HereDescription": "Notificar todos neste {title}" + "HereDescription": "Notificar todos neste {title}", + "Guest": "Convidado" } } diff --git a/plugins/contact-assets/lang/ru.json b/plugins/contact-assets/lang/ru.json index 9a5501ab2e..efd2ee0306 100644 --- a/plugins/contact-assets/lang/ru.json +++ b/plugins/contact-assets/lang/ru.json @@ -119,6 +119,7 @@ "Everyone": "Все", "Here": "Здесь", "EveryoneDescription": "Уведомить всех в этом {title}", - "HereDescription": "Уведомить всех в этом{title}" + "HereDescription": "Уведомить всех в этом{title}", + "Guest": "Гость" } } diff --git a/plugins/contact-assets/lang/zh.json b/plugins/contact-assets/lang/zh.json index ed9149683c..848ebf2096 100644 --- a/plugins/contact-assets/lang/zh.json +++ b/plugins/contact-assets/lang/zh.json @@ -119,6 +119,7 @@ "Everyone": "所有人", "Here": "这里", "EveryoneDescription": "通知所有人在此{title}", - "HereDescription": "通知所有人在此{title}" + "HereDescription": "通知所有人在此{title}", + "Guest": "访客" } } diff --git a/plugins/contact-resources/src/components/Avatar.svelte b/plugins/contact-resources/src/components/Avatar.svelte index a2969e67ec..1578aff712 100644 --- a/plugins/contact-resources/src/components/Avatar.svelte +++ b/plugins/contact-resources/src/components/Avatar.svelte @@ -123,7 +123,7 @@ -
+
{#if showStatus && person}
{/if}
- - diff --git a/plugins/contact-resources/src/components/AvatarInstance.svelte b/plugins/contact-resources/src/components/AvatarInstance.svelte index d0ca2f32b8..96e16263a0 100644 --- a/plugins/contact-resources/src/components/AvatarInstance.svelte +++ b/plugins/contact-resources/src/components/AvatarInstance.svelte @@ -34,7 +34,6 @@ export let adaptiveName: boolean = false export let disabled: boolean = false export let style: 'modern' | undefined = undefined - export let clickable: boolean = false function handleClick (): void { dispatch('click') @@ -73,7 +72,7 @@
{/if} - - diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index 9e49652b14..feaeabcc8e 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -346,7 +346,8 @@ export const contactPlugin = plugin(contactId, { Everyone: '' as IntlString, Here: '' as IntlString, EveryoneDescription: '' as IntlString, - HereDescription: '' as IntlString + HereDescription: '' as IntlString, + Guest: '' as IntlString }, viewlet: { TableMember: '' as Ref, diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index e2a99ef602..4f8bb9d858 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -392,7 +392,7 @@ export async function fetchWorkspace (): Promise<[Status, WorkspaceInfoWithStatu } try { - const workspaceWithStatus = await getAccountClient(token).getWorkspaceInfo() + const workspaceWithStatus = await getAccountClient(token).getWorkspaceInfo(true) Analytics.handleEvent('Fetch workspace') Analytics.setWorkspace(workspaceWithStatus.url) diff --git a/services/mail/mail-common/src/__tests__/channel.test.ts b/services/mail/mail-common/src/__tests__/channel.test.ts index 87736295a4..b35d202200 100644 --- a/services/mail/mail-common/src/__tests__/channel.test.ts +++ b/services/mail/mail-common/src/__tests__/channel.test.ts @@ -154,6 +154,82 @@ describe('ChannelCache', () => { // Verify the cache doesn't contain the failed lookup expect((channelCache as any).cache.has(`${spaceId}:${emailAccount}`)).toBe(false) }) + + it('should not create duplicate channels for email addresses with different case', async () => { + // Arrange + const existingChannelId = 'mixed-case-channel-id' + const lowerCaseEmail = 'mixedcase@example.com' + const upperCaseEmail = 'MixedCase@Example.com' + + // Mock first findOne to return null (channel doesn't exist yet) + // and second findOne to return the channel (after creation) + mockClient.findOne + .mockResolvedValueOnce(undefined) // First call: channel doesn't exist + .mockResolvedValueOnce({ + // Second call after creation with different case + _id: existingChannelId as any, + title: lowerCaseEmail + } as any) + + mockClient.createDoc.mockResolvedValueOnce(existingChannelId as any) + + // Act - First create a channel with lowercase email + const channelId1 = await channelCache.getOrCreateChannel(spaceId, participants, lowerCaseEmail, personId) + + // Clear cache to simulate a fresh lookup + channelCache.clearCache(spaceId, lowerCaseEmail) + + // Act - Then try to create with uppercase email + const channelId2 = await channelCache.getOrCreateChannel(spaceId, participants, upperCaseEmail, personId) + + // Assert + expect(mockClient.findOne).toHaveBeenNthCalledWith(1, mail.tag.MailChannel, { title: lowerCaseEmail }) + expect(mockClient.findOne).toHaveBeenNthCalledWith(2, mail.tag.MailChannel, { title: lowerCaseEmail }) + + // Should only create doc once + expect(mockClient.createDoc).toHaveBeenCalledTimes(1) + expect(mockClient.createMixin).toHaveBeenCalledTimes(1) + + // Both should return the same channel ID + expect(channelId1).toBe(existingChannelId) + expect(channelId2).toBe(existingChannelId) + expect(channelId1).toBe(channelId2) + }) + + it('should handle race conditions when creating channels', async () => { + // Arrange - Simulate a race condition where channel is created between mutex lock and double-check + const raceChannelId = 'race-condition-channel-id' + + // Mock behavior: + // 1. First findOne returns null (channel doesn't exist) + // 2. Second findOne (after mutex) returns a channel (someone else created it) + mockClient.findOne.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ + _id: raceChannelId, + title: 'race@example.com' + } as any) + + // Act + const channelId = await channelCache.getOrCreateChannel(spaceId, participants, 'race@example.com', personId) + + // Assert + expect(mockClient.findOne).toHaveBeenCalledTimes(2) + expect(mockClient.createDoc).not.toHaveBeenCalled() // Should not create doc because of race check + expect(channelId).toBe(raceChannelId) + }) + + it('should normalize email addresses to lowercase before lookup and creation', async () => { + // Arrange - This test verifies that email normalization to lowercase is implemented + + const mixedCaseEmail = 'MiXeD@ExAmPlE.com' + + // Act + await channelCache.getOrCreateChannel(spaceId, participants, mixedCaseEmail, personId) + + // Assert - If email normalization is implemented, this would pass + expect(mockClient.findOne).toHaveBeenCalledWith(mail.tag.MailChannel, { + title: expect.stringMatching(/mixed@example\.com/i) + }) + }) }) describe('clearCache', () => { diff --git a/services/mail/mail-common/src/channel.ts b/services/mail/mail-common/src/channel.ts index 3a024308e6..b0405c46b5 100644 --- a/services/mail/mail-common/src/channel.ts +++ b/services/mail/mail-common/src/channel.ts @@ -25,7 +25,7 @@ const createMutex = new SyncMutex() * Caches channel references to reduce calls to create mail channels */ export class ChannelCache { - // Key is `${spaceId}:${emailAccount}` + // Key is `${spaceId}:${normalizedEmail}` private readonly cache = new Map>() constructor ( @@ -40,17 +40,18 @@ export class ChannelCache { async getOrCreateChannel ( spaceId: Ref, participants: PersonId[], - emailAccount: string, + email: string, owner: PersonId ): Promise | undefined> { - const cacheKey = `${spaceId}:${emailAccount}` + const normalizedEmail = normalizeEmail(email) + const cacheKey = `${spaceId}:${normalizedEmail}` let channel = this.cache.get(cacheKey) if (channel != null) { return channel } - channel = await this.fetchOrCreateChannel(spaceId, participants, emailAccount, owner) + channel = await this.fetchOrCreateChannel(spaceId, participants, normalizedEmail, owner) if (channel != null) { this.cache.set(cacheKey, channel) } @@ -58,8 +59,9 @@ export class ChannelCache { return channel } - clearCache (spaceId: Ref, emailAccount: string): void { - this.cache.delete(`${spaceId}:${emailAccount}`) + clearCache (spaceId: Ref, email: string): void { + const normalizedEmail = normalizeEmail(email) + this.cache.delete(`${spaceId}:${normalizedEmail}`) } clearAllCache (): void { @@ -73,29 +75,30 @@ export class ChannelCache { private async fetchOrCreateChannel ( space: Ref, participants: PersonId[], - emailAccount: string, + email: string, personId: PersonId ): Promise | undefined> { + const normalizedEmail = normalizeEmail(email) try { // First try to find existing channel - const channel = await this.client.findOne(mail.tag.MailChannel, { title: emailAccount }) + const channel = await this.client.findOne(mail.tag.MailChannel, { title: normalizedEmail }) if (channel != null) { - this.ctx.info('Using existing channel', { me: emailAccount, space, channel: channel._id }) + this.ctx.info('Using existing channel', { me: normalizedEmail, space, channel: channel._id }) return channel._id } - return await this.createNewChannel(space, participants, emailAccount, personId) + return await this.createNewChannel(space, participants, normalizedEmail, personId) } catch (err) { this.ctx.error('Failed to create channel', { - me: emailAccount, + me: normalizedEmail, space, workspace: this.workspace, error: err instanceof Error ? err.message : String(err) }) // Remove failed lookup from cache - this.cache.delete(`${space}:${emailAccount}`) + this.cache.delete(`${space}:${normalizedEmail}`) return undefined } @@ -104,18 +107,19 @@ export class ChannelCache { private async createNewChannel ( space: Ref, participants: PersonId[], - emailAccount: string, + email: string, personId: PersonId ): Promise | undefined> { - const mutexKey = `channel:${this.workspace}:${space}:${emailAccount}` + const normalizedEmail = normalizeEmail(email) + const mutexKey = `channel:${this.workspace}:${space}:${normalizedEmail}` const releaseLock = await createMutex.lock(mutexKey) try { // Double-check that channel doesn't exist after acquiring lock - const existingChannel = await this.client.findOne(mail.tag.MailChannel, { title: emailAccount }) + const existingChannel = await this.client.findOne(mail.tag.MailChannel, { title: normalizedEmail }) if (existingChannel != null) { this.ctx.info('Using existing channel (found after mutex lock)', { - me: emailAccount, + me: normalizedEmail, space, channel: existingChannel._id }) @@ -123,12 +127,12 @@ export class ChannelCache { } // Create new channel if it doesn't exist - this.ctx.info('Creating new channel', { me: emailAccount, space, personId }) + this.ctx.info('Creating new channel', { me: normalizedEmail, space, personId }) const channelId = await this.client.createDoc( chat.masterTag.Channel, space, { - title: emailAccount, + title: normalizedEmail, private: true, members: participants, archived: false, @@ -140,7 +144,7 @@ export class ChannelCache { personId ) - this.ctx.info('Creating mixin', { me: emailAccount, space, personId, channelId }) + this.ctx.info('Creating mixin', { me: normalizedEmail, space, personId, channelId }) await this.client.createMixin( channelId, chat.masterTag.Channel, @@ -187,3 +191,7 @@ export const ChannelCacheFactory = { return ChannelCacheFactory.instances.size } } + +function normalizeEmail (email: string): string { + return email.toLowerCase().trim() +}