Skip to content
Merged
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
29 changes: 12 additions & 17 deletions plugins/login-resources/src/components/InviteLink.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -163,7 +158,7 @@
{#if userRoleSelectComponent}
<svelte:component
this={userRoleSelectComponent}
selected={role ?? AccountRole.User}
selected={role ?? defaultInviteRole}
on:selected={handleInviteRoleSelected}
/>
{/if}
Expand Down Expand Up @@ -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)
}
}}
/>
Expand Down
4 changes: 3 additions & 1 deletion plugins/setting-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
176 changes: 176 additions & 0 deletions plugins/setting-resources/src/__tests__/inviteSettingsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getMetadata>

function inviteDoc (
partial: Partial<InviteSettings> & Pick<InviteSettings, 'expirationTime' | 'emailMask' | 'limit'>
): 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)
})
})
})
67 changes: 67 additions & 0 deletions plugins/setting-resources/src/__tests__/roleCapability.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading
Loading