Reuse default invite settings in link generation (#10651)

* Reuse default invite settings in link generation

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Capability check

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix warning

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-19 14:35:06 +07:00
committed by GitHub
parent dc9744ba12
commit 195d7dbff2
9 changed files with 391 additions and 73 deletions
@@ -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}
<svelte:component
this={userRoleSelectComponent}
selected={role ?? AccountRole.User}
selected={role ?? defaultInviteRole}
on:selected={handleInviteRoleSelected}
/>
{/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)
}
}}
/>
+3 -1
View File
@@ -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",
@@ -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)
})
})
})
@@ -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)
})
})
})
@@ -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<string> | 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
}
@@ -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()
+1
View File
@@ -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,
@@ -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<string | undefined>
DefaultInviteLinkGeneratorRoles: Metadata<string[] | undefined>
}
const numericAccountRoles = new Set<number>(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<string | AccountRole> | 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<string | AccountRole>,
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
}
}
@@ -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<string, AccountRole[]> = {
[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 (