diff --git a/plugins/login-resources/src/components/InviteLink.svelte b/plugins/login-resources/src/components/InviteLink.svelte index d5e5614ea0..8ca7f7998f 100644 --- a/plugins/login-resources/src/components/InviteLink.svelte +++ b/plugins/login-resources/src/components/InviteLink.svelte @@ -16,6 +16,7 @@ import { AccountRole, getCurrentAccount, hasAccountRole, Timestamp } from '@hcengineering/core' import { copyTextToClipboard, createQuery } from '@hcengineering/presentation' import setting, { RoleCapability } from '@hcengineering/setting' + import { getDefaultInviteRole, resolveInviteSettings } from '@hcengineering/setting-resources' import { getResource } from '@hcengineering/platform' import { AnySvelteComponent, Button, EditBox, Grid, Label, Loading, MiniToggle, ticker } from '@hcengineering/ui' import { createEventDispatcher } from 'svelte' @@ -38,24 +39,18 @@ limit: number | undefined } + const defaultInviteRole: AccountRole = getDefaultInviteRole() + $: !ignoreSettings && query.query(setting.class.InviteSettings, {}, (set) => { - if (set !== undefined && set.length > 0) { - expHours = set[0].expirationTime - emailMask = set[0].emailMask - limit = set[0].limit - if (role == null) { - role = set[0].defaultInviteRole ?? AccountRole.User - } - } else { - expHours = 48 - limit = -1 - if (role == null) { - role = AccountRole.User - } + const state = resolveInviteSettings(set?.[0]) + expHours = state.expirationTime + emailMask = state.emailMask + limit = state.limit + if (role == null) { + role = state.defaultInviteRole } - - if (limit === -1) noLimit = true + if (state.noLimit) noLimit = true defaultValues = { expirationTime: expHours, @@ -163,7 +158,7 @@ {#if userRoleSelectComponent} {/if} @@ -201,7 +196,7 @@ if (!canGenerateInviteLinks) return const effectiveLimit = limit ?? 0 if (effectiveLimit > 0 || noLimit) { - void getLink(expHours, emailMask, limit, role ?? AccountRole.User) + void getLink(expHours, emailMask, limit, role ?? defaultInviteRole) } }} /> diff --git a/plugins/setting-resources/package.json b/plugins/setting-resources/package.json index ec379f482d..5fb70c7335 100644 --- a/plugins/setting-resources/package.json +++ b/plugins/setting-resources/package.json @@ -13,7 +13,9 @@ "build:watch": "compile ui", "_phase:build": "compile ui", "_phase:format": "format src", - "_phase:validate": "compile validate" + "_phase:validate": "compile validate", + "_phase:test": "jest --passWithNoTests --silent", + "test": "jest --passWithNoTests --silent" }, "devDependencies": { "svelte-loader": "^3.2.0", diff --git a/plugins/setting-resources/src/__tests__/inviteSettingsUtils.test.ts b/plugins/setting-resources/src/__tests__/inviteSettingsUtils.test.ts new file mode 100644 index 0000000000..753d46165e --- /dev/null +++ b/plugins/setting-resources/src/__tests__/inviteSettingsUtils.test.ts @@ -0,0 +1,176 @@ +// +// 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 { AccountRole } from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import type { InviteSettings } from '@hcengineering/setting' +import { + DEFAULT_INVITE_LINK_GENERATOR_ROLES, + getDefaultInviterRoles, + getDefaultInviteRole, + INVITE_SETTINGS_DEFAULT_EXPIRATION_HOURS, + INVITE_SETTINGS_DEFAULT_LIMIT, + normalizeInviteRole, + normalizeInviteRoles, + resolveInviteSettings +} from '../inviteSettingsUtils' + +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + getMetadata: jest.fn() + } +}) + +const mockGetMetadata = getMetadata as jest.MockedFunction + +function inviteDoc ( + partial: Partial & Pick +): InviteSettings { + return partial as InviteSettings +} + +describe('inviteSettingsUtils', () => { + beforeEach(() => { + mockGetMetadata.mockReturnValue(undefined) + }) + + describe('normalizeInviteRole', () => { + it('maps string role names case-insensitively', () => { + expect(normalizeInviteRole('GUEST', AccountRole.User)).toBe(AccountRole.Guest) + expect(normalizeInviteRole('User', AccountRole.Guest)).toBe(AccountRole.User) + expect(normalizeInviteRole('MAINTAINER', AccountRole.Guest)).toBe(AccountRole.Maintainer) + expect(normalizeInviteRole('owner', AccountRole.Guest)).toBe(AccountRole.Owner) + }) + + it('returns valid AccountRole numbers as-is', () => { + expect(normalizeInviteRole(AccountRole.Maintainer, AccountRole.Guest)).toBe(AccountRole.Maintainer) + }) + + it('returns fallback for unknown string', () => { + expect(normalizeInviteRole('admin', AccountRole.User)).toBe(AccountRole.User) + }) + + it('returns fallback for undefined', () => { + expect(normalizeInviteRole(undefined, AccountRole.Owner)).toBe(AccountRole.Owner) + }) + + it('returns fallback for invalid number', () => { + expect(normalizeInviteRole(999 as unknown as AccountRole, AccountRole.Guest)).toBe(AccountRole.Guest) + }) + }) + + describe('normalizeInviteRoles', () => { + it('returns fallback copy when values missing or empty', () => { + const fallback = [AccountRole.Guest, AccountRole.User] + expect(normalizeInviteRoles(undefined, fallback)).toEqual(fallback) + expect(normalizeInviteRoles([], fallback)).toEqual(fallback) + expect(normalizeInviteRoles(undefined, fallback)).not.toBe(fallback) + }) + + it('maps and deduplicates roles', () => { + expect(normalizeInviteRoles(['user', 'USER', 'maintainer'], [AccountRole.Guest])).toEqual([ + AccountRole.User, + AccountRole.Maintainer + ]) + }) + + it('maps unrecognized strings to User (inner fallback)', () => { + expect(normalizeInviteRoles(['nope', 'x'], [AccountRole.Guest])).toEqual([AccountRole.User]) + }) + }) + + describe('getDefaultInviteRole / getDefaultInviterRoles', () => { + it('uses User and default generator list when metadata unset', () => { + mockGetMetadata.mockReturnValue(undefined) + expect(getDefaultInviteRole()).toBe(AccountRole.User) + expect(getDefaultInviterRoles()).toEqual(DEFAULT_INVITE_LINK_GENERATOR_ROLES) + }) + + it('reads default invite role from metadata string', () => { + mockGetMetadata.mockReturnValue('maintainer') + expect(getDefaultInviteRole()).toBe(AccountRole.Maintainer) + }) + }) + + describe('resolveInviteSettings', () => { + it('returns defaults when doc is undefined', () => { + mockGetMetadata.mockReturnValue(undefined) + const r = resolveInviteSettings(undefined) + expect(r).toEqual({ + expirationTime: INVITE_SETTINGS_DEFAULT_EXPIRATION_HOURS, + emailMask: '', + limit: INVITE_SETTINGS_DEFAULT_LIMIT, + defaultInviteRole: AccountRole.User, + inviteLinkGeneratorRoles: DEFAULT_INVITE_LINK_GENERATOR_ROLES, + noLimit: true + }) + }) + + it('uses doc fields and noLimit when limit is not -1', () => { + const doc = inviteDoc({ + expirationTime: 12, + emailMask: '*@corp.test', + limit: 100, + defaultInviteRole: AccountRole.Guest, + inviteLinkGeneratorRoles: [AccountRole.Owner] + }) + const r = resolveInviteSettings(doc) + expect(r.expirationTime).toBe(12) + expect(r.emailMask).toBe('*@corp.test') + expect(r.limit).toBe(100) + expect(r.defaultInviteRole).toBe(AccountRole.Guest) + expect(r.inviteLinkGeneratorRoles).toEqual([AccountRole.Owner]) + expect(r.noLimit).toBe(false) + }) + + it('sets noLimit true when doc.limit is -1', () => { + const doc = inviteDoc({ + expirationTime: 48, + emailMask: '', + limit: -1, + defaultInviteRole: AccountRole.User, + inviteLinkGeneratorRoles: [AccountRole.User] + }) + expect(resolveInviteSettings(doc).noLimit).toBe(true) + }) + + it('uses DEFAULT_INVITE_LINK_GENERATOR_ROLES copy when doc list empty', () => { + const doc = inviteDoc({ + expirationTime: 48, + emailMask: '', + limit: -1, + defaultInviteRole: AccountRole.User, + inviteLinkGeneratorRoles: [] + }) + const r = resolveInviteSettings(doc) + expect(r.inviteLinkGeneratorRoles).toEqual(DEFAULT_INVITE_LINK_GENERATOR_ROLES) + expect(r.inviteLinkGeneratorRoles).not.toBe(DEFAULT_INVITE_LINK_GENERATOR_ROLES) + }) + + it('normalizes string defaultInviteRole using metadata fallback', () => { + mockGetMetadata.mockReturnValue('user') + const doc = inviteDoc({ + expirationTime: 1, + emailMask: '', + limit: -1, + defaultInviteRole: 'guest' as unknown as AccountRole, + inviteLinkGeneratorRoles: [AccountRole.User] + }) + expect(resolveInviteSettings(doc).defaultInviteRole).toBe(AccountRole.Guest) + }) + }) +}) diff --git a/plugins/setting-resources/src/__tests__/roleCapability.test.ts b/plugins/setting-resources/src/__tests__/roleCapability.test.ts new file mode 100644 index 0000000000..48ed06d093 --- /dev/null +++ b/plugins/setting-resources/src/__tests__/roleCapability.test.ts @@ -0,0 +1,67 @@ +// +// 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 { AccountRole, type Account } from '@hcengineering/core' +import { RoleCapability } from '@hcengineering/setting' +import { DEFAULT_INVITE_LINK_GENERATOR_ROLES } from '../inviteSettingsUtils' +import { getRolesForCapability, hasRoleCapability } from '../roleCapability' + +function account (role: AccountRole): Account { + return { role } as any +} + +describe('roleCapability (Generate invite link permission)', () => { + describe('getRolesForCapability', () => { + it('uses RoleCapabilitySettings when set', () => { + expect( + getRolesForCapability(RoleCapability.GenerateInviteLink, { + [RoleCapability.GenerateInviteLink]: [AccountRole.Owner] + }) + ).toEqual([AccountRole.Owner]) + }) + + it('uses inviteLinkGeneratorRoles when capability map missing', () => { + expect( + getRolesForCapability(RoleCapability.GenerateInviteLink, undefined, [AccountRole.Guest, AccountRole.User]) + ).toEqual([AccountRole.Guest, AccountRole.User]) + }) + + it('falls back to DEFAULT_INVITE_LINK_GENERATOR_ROLES (shared with invite settings)', () => { + expect(getRolesForCapability(RoleCapability.GenerateInviteLink, undefined, undefined)).toBe( + DEFAULT_INVITE_LINK_GENERATOR_ROLES + ) + }) + }) + + describe('hasRoleCapability', () => { + it('allows User when falling back to default generator roles', () => { + expect( + hasRoleCapability(account(AccountRole.User), RoleCapability.GenerateInviteLink, undefined, undefined) + ).toBe(true) + }) + + it('denies User when only Owner may generate', () => { + expect( + hasRoleCapability(account(AccountRole.User), RoleCapability.GenerateInviteLink, undefined, [AccountRole.Owner]) + ).toBe(false) + }) + + it('allows Owner when only Owner may generate', () => { + expect( + hasRoleCapability(account(AccountRole.Owner), RoleCapability.GenerateInviteLink, undefined, [AccountRole.Owner]) + ).toBe(true) + }) + }) +}) diff --git a/plugins/setting-resources/src/components/InviteSetting.svelte b/plugins/setting-resources/src/components/InviteSetting.svelte index 97d8793b28..73982e95e6 100644 --- a/plugins/setting-resources/src/components/InviteSetting.svelte +++ b/plugins/setting-resources/src/components/InviteSetting.svelte @@ -18,7 +18,8 @@ import { createQuery, getClient } from '@hcengineering/presentation' import setting, { type InviteSettings, type RoleCapabilitySettings, RoleCapability } from '@hcengineering/setting' import { hasRoleCapability } from '../roleCapability' - import { getMetadata, translate } from '@hcengineering/platform' + import { getDefaultInviterRoles, getDefaultInviteRole, resolveInviteSettings } from '../inviteSettingsUtils' + import { translate } from '@hcengineering/platform' import { Breadcrumb, DropdownLabels, @@ -36,41 +37,13 @@ const client = getClient() - const configDefaultInviteRole = getMetadata(setting.metadata.DefaultInviteRole) - const configInviteLinkGeneratorRoles = getMetadata(setting.metadata.DefaultInviteLinkGeneratorRoles) - - function normalizeRole (value: string | undefined, fallback: AccountRole): AccountRole { - if (typeof value === 'string') { - const normalizedValue = value.toLowerCase() - switch (normalizedValue) { - case 'guest': - return AccountRole.Guest - case 'user': - return AccountRole.User - case 'maintainer': - return AccountRole.Maintainer - case 'owner': - return AccountRole.Owner - } - } - return fallback - } - - function normalizeRoles (values: Array | undefined, fallback: AccountRole[]): AccountRole[] { - if (!Array.isArray(values) || values.length === 0) return [...fallback] - const mapped = values - .map((v) => normalizeRole(v, AccountRole.User)) - .filter((role, index, arr) => arr.indexOf(role) === index) - return mapped.length > 0 ? mapped : [...fallback] - } let loading = true let expTime: number = 48 let mask: string = '' let limit: number | undefined = -1 - const defaultGeneratorRoles: AccountRole[] = [AccountRole.User, AccountRole.Maintainer, AccountRole.Owner] - let defaultInviteRole: AccountRole = normalizeRole(configDefaultInviteRole, AccountRole.User) - let inviteLinkGeneratorRoles: AccountRole[] = normalizeRoles(configInviteLinkGeneratorRoles, defaultGeneratorRoles) + let defaultInviteRole: AccountRole = getDefaultInviteRole() + let inviteLinkGeneratorRoles: AccountRole[] = getDefaultInviterRoles() let noLimit: boolean = true let existingInviteSettings: InviteSettings[] = [] let existingRoleCapabilitySettings: { @@ -108,24 +81,13 @@ function applyInviteSettings (set: InviteSettings[]): void { existingInviteSettings = set - if (existingInviteSettings.length > 0) { - const first = existingInviteSettings[0] - expTime = first.expirationTime - mask = first.emailMask - limit = first.limit - defaultInviteRole = normalizeRole(first.defaultInviteRole, defaultInviteRole) - inviteLinkGeneratorRoles = - first.inviteLinkGeneratorRoles != null && first.inviteLinkGeneratorRoles.length > 0 - ? normalizeRoles(first.inviteLinkGeneratorRoles, defaultGeneratorRoles) - : [...defaultGeneratorRoles] - } else { - expTime = 48 - mask = '' - limit = -1 - defaultInviteRole = normalizeRole(configDefaultInviteRole, AccountRole.User) - inviteLinkGeneratorRoles = normalizeRoles(configInviteLinkGeneratorRoles, defaultGeneratorRoles) - } - noLimit = limit === -1 + const state = resolveInviteSettings(set[0]) + expTime = state.expirationTime + mask = state.emailMask + limit = state.limit + defaultInviteRole = state.defaultInviteRole + inviteLinkGeneratorRoles = state.inviteLinkGeneratorRoles + noLimit = state.noLimit loading = false } diff --git a/plugins/setting-resources/src/hasRoleCapabilityAsync.ts b/plugins/setting-resources/src/hasRoleCapabilityAsync.ts index 7835649093..3b0144d413 100644 --- a/plugins/setting-resources/src/hasRoleCapabilityAsync.ts +++ b/plugins/setting-resources/src/hasRoleCapabilityAsync.ts @@ -17,6 +17,7 @@ import { getCurrentAccount } from '@hcengineering/core' import { getClient } from '@hcengineering/presentation' import setting, { RoleCapability } from '@hcengineering/setting' import { hasRoleCapability } from './roleCapability' +import { resolveInviteSettings } from './inviteSettingsUtils' /** * Returns whether the current account has the given role capability. @@ -32,10 +33,8 @@ export async function hasRoleCapabilityAsync (capabilityId: RoleCapabilityId | s const firstInvite = inviteSettings[0] as InviteSettings | undefined const firstRoleCap = roleCapabilitySettings[0] as RoleCapabilitySettings | undefined const inviteLinkGeneratorRoles = - capabilityId === RoleCapability.GenerateInviteLink && - firstInvite?.inviteLinkGeneratorRoles != null && - firstInvite.inviteLinkGeneratorRoles.length > 0 - ? firstInvite.inviteLinkGeneratorRoles + capabilityId === RoleCapability.GenerateInviteLink + ? resolveInviteSettings(firstInvite).inviteLinkGeneratorRoles : undefined const roleByCapability = firstRoleCap?.roleByCapability const account = getCurrentAccount() diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 5248ce157a..30a09f6a3f 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -82,6 +82,7 @@ import { hasRoleCapabilityAsync } from './hasRoleCapabilityAsync' export * from './store' export { hasRoleCapability, getRolesForCapability } from './roleCapability' export { hasRoleCapabilityAsync } from './hasRoleCapabilityAsync' +export * from './inviteSettingsUtils' export { ClassAttributes, ClassAttributesList, diff --git a/plugins/setting-resources/src/inviteSettingsUtils.ts b/plugins/setting-resources/src/inviteSettingsUtils.ts new file mode 100644 index 0000000000..d6d29454c4 --- /dev/null +++ b/plugins/setting-resources/src/inviteSettingsUtils.ts @@ -0,0 +1,114 @@ +// +// 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 { AccountRole } from '@hcengineering/core' +import { getMetadata, type Metadata } from '@hcengineering/platform' +import setting, { type InviteSettings } from '@hcengineering/setting' + +const settingInviteMetadata = setting.metadata as unknown as { + DefaultInviteRole: Metadata + DefaultInviteLinkGeneratorRoles: Metadata +} + +const numericAccountRoles = new Set(Object.values(AccountRole).filter((v) => typeof v === 'number') as number[]) + +export function normalizeInviteRole (value: string | AccountRole | undefined, fallback: AccountRole): AccountRole { + if (value !== undefined && typeof value === 'number' && numericAccountRoles.has(value)) { + return value as AccountRole + } + if (typeof value === 'string') { + const n = value.toLowerCase() + switch (n) { + case 'guest': + return AccountRole.Guest + case 'user': + return AccountRole.User + case 'maintainer': + return AccountRole.Maintainer + case 'owner': + return AccountRole.Owner + } + } + return fallback +} + +export function normalizeInviteRoles ( + values: Array | undefined, + fallback: AccountRole[] +): AccountRole[] { + if (!Array.isArray(values) || values.length === 0) return [...fallback] + const mapped = values.map((v) => normalizeInviteRole(v, AccountRole.User)).filter((r, i, arr) => arr.indexOf(r) === i) + return mapped.length > 0 ? mapped : [...fallback] +} + +export const DEFAULT_INVITE_LINK_GENERATOR_ROLES: AccountRole[] = [ + AccountRole.User, + AccountRole.Maintainer, + AccountRole.Owner +] + +export const INVITE_SETTINGS_DEFAULT_EXPIRATION_HOURS = 48 +export const INVITE_SETTINGS_DEFAULT_LIMIT = -1 + +export function getDefaultInviteRole (): AccountRole { + return normalizeInviteRole(getMetadata(settingInviteMetadata.DefaultInviteRole), AccountRole.User) +} + +export function getDefaultInviterRoles (): AccountRole[] { + return normalizeInviteRoles( + getMetadata(settingInviteMetadata.DefaultInviteLinkGeneratorRoles), + DEFAULT_INVITE_LINK_GENERATOR_ROLES + ) +} + +export interface ResolvedInviteSettings { + expirationTime: number + emailMask: string + limit: number + defaultInviteRole: AccountRole + inviteLinkGeneratorRoles: AccountRole[] + noLimit: boolean +} + +export function resolveInviteSettings (doc: InviteSettings | undefined): ResolvedInviteSettings { + const metaRole = getDefaultInviteRole() + const metaGenerators = getDefaultInviterRoles() + + if (doc != null) { + return { + expirationTime: doc.expirationTime, + emailMask: doc.emailMask, + limit: doc.limit, + defaultInviteRole: normalizeInviteRole(doc.defaultInviteRole as string | AccountRole | undefined, metaRole), + inviteLinkGeneratorRoles: + doc.inviteLinkGeneratorRoles != null && doc.inviteLinkGeneratorRoles.length > 0 + ? normalizeInviteRoles( + doc.inviteLinkGeneratorRoles as Array, + DEFAULT_INVITE_LINK_GENERATOR_ROLES + ) + : [...DEFAULT_INVITE_LINK_GENERATOR_ROLES], + noLimit: doc.limit === -1 + } + } + + return { + expirationTime: INVITE_SETTINGS_DEFAULT_EXPIRATION_HOURS, + emailMask: '', + limit: INVITE_SETTINGS_DEFAULT_LIMIT, + defaultInviteRole: metaRole, + inviteLinkGeneratorRoles: metaGenerators, + noLimit: true + } +} diff --git a/plugins/setting-resources/src/roleCapability.ts b/plugins/setting-resources/src/roleCapability.ts index 0157f21640..e63efdf338 100644 --- a/plugins/setting-resources/src/roleCapability.ts +++ b/plugins/setting-resources/src/roleCapability.ts @@ -16,15 +16,17 @@ import type { Account } from '@hcengineering/core' import { AccountRole, hasAccountRole } from '@hcengineering/core' import { RoleCapability, type RoleCapabilityId } from '@hcengineering/setting' +import { DEFAULT_INVITE_LINK_GENERATOR_ROLES } from './inviteSettingsUtils' + /** Default roles that have each capability when no RoleCapabilitySettings is set */ const DEFAULT_ROLES_BY_CAPABILITY: Record = { - [RoleCapability.GenerateInviteLink]: [AccountRole.User, AccountRole.Maintainer, AccountRole.Owner], + [RoleCapability.GenerateInviteLink]: DEFAULT_INVITE_LINK_GENERATOR_ROLES, [RoleCapability.ManageInviteSettings]: [AccountRole.Maintainer, AccountRole.Owner] } /** * Returns whether the given account has the specified capability. - * Uses roleByCapability if provided; otherwise for GenerateInviteLink falls back to inviteLinkGeneratorRoles; otherwise uses built-in defaults. + * Uses roleByCapability if provided; otherwise for GenerateInviteLink falls back to inviteLinkGeneratorRoles (from workspace settings / resolved defaults); otherwise {@link DEFAULT_INVITE_LINK_GENERATOR_ROLES}. * @public */ export function hasRoleCapability (