Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions models/contact/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ export class TEmployee extends TPerson implements Employee {
position?: string | null

declare personUuid?: AccountUuid

@Prop(TypeString(), contact.string.Timezone)
@Hidden()
timezone?: string
}

@Model(contact.class.ContactsTab, core.class.Doc, DOMAIN_MODEL)
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Uživatelský profil",
"DeactivatedAccount": "Deaktivovaný účet",
"LocalTime": "místní čas",
"Timezone": "Časové pásmo",
"Everyone": "Všichni",
"Here": "Zde",
"EveryoneDescription": "Upozornit všechny v tomto {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Benutzerprofil",
"DeactivatedAccount": "Deaktivierter Account",
"LocalTime": "Ortszeit",
"Timezone": "Zeitzone",
"Everyone": "Jeder",
"Here": "Hier",
"EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "User profile",
"DeactivatedAccount": "Deactivated account",
"LocalTime": "local time",
"Timezone": "Timezone",
"Everyone": "Everyone",
"Here": "Here",
"EveryoneDescription": "Notify everyone in this {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Perfil de usuario",
"DeactivatedAccount": "Cuenta desactivada",
"LocalTime": "hora local",
"Timezone": "Zona horaria",
"Everyone": "Todos",
"Here": "Aquí",
"EveryoneDescription": "Notificar a todos en este {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Profil utilisateur",
"DeactivatedAccount": "Compte désactivé",
"LocalTime": "heure locale",
"Timezone": "Fuseau horaire",
"Everyone": "Tout le monde",
"Here": "Ici",
"EveryoneDescription": "Notifier tout le monde dans ce {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Profilo utente",
"DeactivatedAccount": "Account disattivato",
"LocalTime": "ora locale",
"Timezone": "Fuso orario",
"Everyone": "Tutti",
"Here": "Qui",
"EveryoneDescription": "Notifica tutti in questo {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "ユーザープロフィール",
"DeactivatedAccount": "アカウントは無効化されました",
"LocalTime": "現地時間",
"Timezone": "タイムゾーン",
"Everyone": "全員",
"Here": "ここ",
"EveryoneDescription": "この{title}のすべてに通知する",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "사용자 프로필",
"DeactivatedAccount": "비활성화된 계정",
"LocalTime": "현지 시간",
"Timezone": "시간대",
"Everyone": "모두",
"Here": "여기",
"EveryoneDescription": "이 {title}의 모든 멤버에게 알림",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Perfil do usuário",
"DeactivatedAccount": "Conta desativada",
"LocalTime": "hora local",
"Timezone": "Fuso horário",
"Everyone": "Todos",
"Here": "Aqui",
"EveryoneDescription": "Notificar todos em {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Perfil do usuário",
"DeactivatedAccount": "Conta desativada",
"LocalTime": "hora local",
"Timezone": "Fuso horário",
"Everyone": "Todos",
"Here": "Aqui",
"EveryoneDescription": "Notificar todos neste {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Профиль пользователя",
"DeactivatedAccount": "Деактивированный аккаунт",
"LocalTime": "местного времени",
"Timezone": "Часовой пояс",
"Everyone": "Все",
"Here": "Здесь",
"EveryoneDescription": "Уведомить всех в этом {title}",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "Kullanıcı profili",
"DeactivatedAccount": "Devre dışı hesap",
"LocalTime": "yerel saat",
"Timezone": "Saat dilimi",
"Everyone": "Herkes",
"Here": "Burada",
"EveryoneDescription": "Bu {title} içindeki herkesi bilgilendir",
Expand Down
1 change: 1 addition & 0 deletions plugins/contact-assets/lang/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"UserProfile": "用户资料",
"DeactivatedAccount": "已停用账户",
"LocalTime": "当地时间",
"Timezone": "时区",
"Everyone": "所有人",
"Here": "这里",
"EveryoneDescription": "通知所有人在此{title}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import DeactivatedHeader from './DeactivatedHeader.svelte'
import ModernProfilePopup from './ModernProfilePopup.svelte'
import TimePresenter from './TimePresenter.svelte'
import { getPersonTimezone } from './utils'

export let _id: Ref<Employee>
export let disabled: boolean = false
Expand All @@ -40,13 +39,12 @@
const hierarchy = client.getHierarchy()

let employee: Employee | Person | undefined = undefined
let timezone: string | undefined = undefined
let isEmployee: boolean = false

$: personByRefStore = getPersonByPersonRefStore([_id])
$: employee = $employeeByIdStore.get(_id) ?? $personByRefStore.get(_id)
$: isEmployee = $employeeByIdStore.has(_id)
$: void loadPersonTimezone(employee)
$: timezone = isEmployee ? (employee as Employee | undefined)?.timezone : undefined

const levelQuery = createQuery()

Expand All @@ -69,12 +67,6 @@
navigate(loc)
}

async function loadPersonTimezone (person: Employee | Person | undefined): Promise<void> {
if (person?.personUuid !== undefined && isEmployee) {
timezone = await getPersonTimezone(person?.personUuid as AccountUuid)
}
}

$: statusSubtitle =
employee?.personUuid !== undefined
? getWorkspaceMemberStatusSubtitle($workspaceMemberStatusByAccountStore.get(employee.personUuid as AccountUuid))
Expand Down
30 changes: 1 addition & 29 deletions plugins/contact-resources/src/components/person/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { get, writable } from 'svelte/store'

import type { AccountUuid, Class, Ref } from '@hcengineering/core'
import type { Class, Ref } from '@hcengineering/core'
import type { Person } from '@hcengineering/contact'
import { getClient } from '@hcengineering/presentation'
import type { LabelAndProps } from '@hcengineering/ui'

import contact from '../../plugin'
import EmployeePreviewPopup from './EmployeePreviewPopup.svelte'
import { getAccountClient } from '../../utils'

const client = getClient()
const h = client.getHierarchy()
Expand All @@ -39,28 +36,3 @@ export function getPreviewPopup (
noArrow: true
}
}

export const timezoneByAccountStore = writable<Map<AccountUuid, string>>(new Map())

export async function getPersonTimezone (personId: AccountUuid | undefined): Promise<string | undefined> {
if (personId === undefined) return undefined

const storedTimezone = get(timezoneByAccountStore).get(personId)
if (storedTimezone !== undefined) return storedTimezone

try {
const accountInfo = await getAccountClient().getAccountInfo(personId)
if (accountInfo.timezone !== undefined) {
timezoneByAccountStore.update((store: Map<AccountUuid, string>) => {
if (accountInfo.timezone !== undefined) {
store.set(personId, accountInfo.timezone)
}
return store
})
}
return accountInfo.timezone
} catch (error) {
console.error(error)
return undefined
}
}
33 changes: 33 additions & 0 deletions plugins/contact-resources/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,39 @@ export const myEmployeeStore = derived(
}
)

function detectBrowserTimezone (): string | undefined {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
return tz !== '' ? tz : undefined
} catch {
return undefined
}
}

let timezoneSyncInFlight = false

async function syncMyEmployeeTimezone (employee: WithLookup<Employee> | undefined): Promise<void> {
if (timezoneSyncInFlight || employee === undefined) return
const browserTz = detectBrowserTimezone()
if (browserTz === undefined || employee.timezone === browserTz) return

timezoneSyncInFlight = true
try {
const client = getClient()
await client.updateMixin(employee._id, contact.class.Person, employee.space, contact.mixin.Employee, {
timezone: browserTz
})
} catch (err) {
console.error('Failed to sync employee timezone', err)
} finally {
timezoneSyncInFlight = false
}
}

myEmployeeStore.subscribe((employee) => {
void syncMyEmployeeTimezone(employee)
})

/**
* [Ref<Employee> => PersonId (primary)] mapping
*/
Expand Down
2 changes: 2 additions & 0 deletions plugins/contact/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export interface Employee extends Person {
statuses?: number
position?: string | null
personUuid?: AccountUuid
timezone?: string
}

/**
Expand Down Expand Up @@ -385,6 +386,7 @@ export const contactPlugin = plugin(contactId, {
UserProfile: '' as IntlString,
DeactivatedAccount: '' as IntlString,
LocalTime: '' as IntlString,
Timezone: '' as IntlString,
Everyone: '' as IntlString,
Here: '' as IntlString,
EveryoneDescription: '' as IntlString,
Expand Down
98 changes: 98 additions & 0 deletions server/account/src/__tests__/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
changePassword,
getPerson,
getSocialIds,
getAccountInfo,
createAccessLink,
getSubscriptions,
leaveWorkspace
Expand Down Expand Up @@ -2671,6 +2672,103 @@ describe('account operations', () => {
)
})
})

describe('getAccountInfo', () => {
const callerAccount = 'caller-account-uuid' as AccountUuid
const otherAccount = 'other-account-uuid' as AccountUuid

const accountInfoDb = {
account: {
findOne: jest.fn()
}
} as unknown as AccountDB

beforeEach(() => {
jest.clearAllMocks()
})

test('returns own info when accountId matches caller', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue({
uuid: callerAccount,
timezone: 'America/New_York',
locale: 'en-US',
tfaSecret: 'secret'
})

const result = await getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, {
accountId: callerAccount
})

expect(result).toEqual({ timezone: 'America/New_York', locale: 'en-US', tfaEnabled: true })
expect(accountInfoDb.account.findOne).toHaveBeenCalledWith({ uuid: callerAccount })
})

test('rejects cross-account lookup for non-admin, non-service caller', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })

await expect(
getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, { accountId: otherAccount })
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))

expect(accountInfoDb.account.findOne).not.toHaveBeenCalled()
})

test('allows admin to read other account info', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: { admin: 'true' } })
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue({
uuid: otherAccount,
timezone: 'Europe/London',
locale: 'en-GB',
tfaSecret: null
})

const result = await getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, {
accountId: otherAccount
})

expect(result).toEqual({ timezone: 'Europe/London', locale: 'en-GB', tfaEnabled: false })
expect(accountInfoDb.account.findOne).toHaveBeenCalledWith({ uuid: otherAccount })
})

test('allows workspace service token to read other account info', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: callerAccount,
extra: { service: 'workspace' }
})
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue({
uuid: otherAccount,
timezone: 'Asia/Tokyo',
locale: 'ja-JP',
tfaSecret: null
})

const result = await getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, {
accountId: otherAccount
})

expect(result).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', tfaEnabled: false })
})

test('throws BadRequest when accountId is missing', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })

await expect(
getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, { accountId: '' as AccountUuid })
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))

expect(accountInfoDb.account.findOne).not.toHaveBeenCalled()
})

test('throws AccountNotFound when account does not exist', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue(null)

await expect(
getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, { accountId: callerAccount })
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})))
})
})
})
})

Expand Down
12 changes: 11 additions & 1 deletion server/account/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2398,7 +2398,17 @@ export async function getAccountInfo (
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}

decodeTokenVerbose(ctx, token)
const { account: caller, extra } = decodeTokenVerbose(ctx, token)

if (accountId !== caller) {
const isAdmin = extra?.admin === 'true'
const isAllowedService = verifyAllowedServices(['workspace', 'tool'], extra, false)

if (!isAdmin && !isAllowedService) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}

const account = await getAccount(db, accountId)
if (account === undefined || account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
Expand Down
Loading