mirror of
https://github.com/langgenius/dify.git
synced 2026-07-24 04:58:32 +08:00
fix(auth): validate account before accepting invitations (#39438)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
625ed2a0e7
commit
2b8e40af32
@ -7,10 +7,13 @@ from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import extract_access_token
|
||||
from models import AccountStatus
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import RegisterService, TenantService
|
||||
@ -136,6 +139,12 @@ class ActivateApi(Resource):
|
||||
)
|
||||
@console_ns.response(400, "Already activated or invalid token")
|
||||
def post(self):
|
||||
"""Accept an invitation without letting an existing session act for another account.
|
||||
|
||||
Token-only activation remains available for legacy clients. When the request already
|
||||
carries a console session, that session must belong to the account encoded in the
|
||||
invitation before the token is consumed or tenant membership is changed.
|
||||
"""
|
||||
args = ActivatePayload.model_validate(console_ns.payload)
|
||||
|
||||
normalized_request_email = args.email.lower() if args.email else None
|
||||
@ -146,6 +155,11 @@ class ActivateApi(Resource):
|
||||
raise AlreadyActivateError()
|
||||
|
||||
account = invitation["account"]
|
||||
if extract_access_token(request):
|
||||
current_account, _ = current_account_with_tenant()
|
||||
if current_account.id != account.id:
|
||||
raise InvitationAccountMismatchError()
|
||||
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
|
||||
@ -13,6 +13,12 @@ class InvalidEmailError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class InvitationAccountMismatchError(BaseHTTPException):
|
||||
error_code = "invitation_account_mismatch"
|
||||
description = "This invitation was sent to another account. Please sign in with the invited account."
|
||||
code = 403
|
||||
|
||||
|
||||
class PasswordMismatchError(BaseHTTPException):
|
||||
error_code = "password_mismatch"
|
||||
description = "The passwords do not match."
|
||||
|
||||
@ -260,7 +260,12 @@ Get account avatar url
|
||||
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
|
||||
|
||||
### [POST] /activate
|
||||
**Accept an invitation without letting an existing session act for another account**
|
||||
|
||||
Activate account with invitation token
|
||||
Token-only activation remains available for legacy clients. When the request already
|
||||
carries a console session, that session must belong to the account encoded in the
|
||||
invitation before the token is consumed or tenant membership is changed.
|
||||
|
||||
#### Request Body
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
|
||||
@ -202,6 +203,50 @@ class TestActivateApi:
|
||||
with patch("controllers.console.auth.activate.TenantService.switch_tenant") as mock:
|
||||
yield mock
|
||||
|
||||
@patch("controllers.console.auth.activate.TenantService.create_tenant_member")
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.current_account_with_tenant")
|
||||
@patch("controllers.console.auth.activate.extract_access_token", return_value="access-token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
def test_activation_rejects_invitation_for_different_authenticated_account(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_extract_access_token: MagicMock,
|
||||
mock_current_account_with_tenant: MagicMock,
|
||||
mock_revoke_token: MagicMock,
|
||||
mock_get_invitation: MagicMock,
|
||||
mock_create_tenant_member: MagicMock,
|
||||
app: Flask,
|
||||
mock_invitation: MagicMock,
|
||||
mock_account: MagicMock,
|
||||
mock_switch_tenant: MagicMock,
|
||||
):
|
||||
"""A logged-in account cannot consume another account's invitation token."""
|
||||
current_account = MagicMock()
|
||||
current_account.id = "current-account-id"
|
||||
mock_account.id = "invited-account-id"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_invitation["data"]["requires_setup"] = False
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
mock_current_account_with_tenant.return_value = (current_account, "current-workspace-id")
|
||||
|
||||
with app.test_request_context(
|
||||
"/activate",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "valid_token",
|
||||
},
|
||||
):
|
||||
with pytest.raises(InvitationAccountMismatchError):
|
||||
ActivateApi().post()
|
||||
|
||||
mock_extract_access_token.assert_called_once()
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_create_tenant_member.assert_not_called()
|
||||
mock_switch_tenant.assert_not_called()
|
||||
mock_db.session.scalar.assert_not_called()
|
||||
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
|
||||
@ -29,15 +29,22 @@ export const check = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation without letting an existing session act for another account
|
||||
*
|
||||
* Activate account with invitation token
|
||||
* Token-only activation remains available for legacy clients. When the request already
|
||||
* carries a console session, that session must belong to the account encoded in the
|
||||
* invitation before the token is consumed or tenant membership is changed.
|
||||
*/
|
||||
export const post = oc
|
||||
.route({
|
||||
description: 'Activate account with invitation token',
|
||||
description:
|
||||
'Activate account with invitation token\nToken-only activation remains available for legacy clients. When the request already\ncarries a console session, that session must belong to the account encoded in the\ninvitation before the token is consumed or tenant membership is changed.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postActivate',
|
||||
path: '/activate',
|
||||
summary: 'Accept an invitation without letting an existing session act for another account',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPostActivateBody }))
|
||||
|
||||
@ -53,6 +53,7 @@ const loggedInQueryResult = {
|
||||
data: {
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
email: 'invitee@example.com',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
@ -77,6 +78,16 @@ const nonInviteQueryResult = {
|
||||
data: undefined,
|
||||
}
|
||||
|
||||
const mockQueryResults = (
|
||||
profileResult: ReturnType<typeof useQuery>,
|
||||
inviteResult: ReturnType<typeof useQuery>,
|
||||
) => {
|
||||
mockUseQuery.mockImplementation((options) => {
|
||||
const queryKey = options.queryKey as readonly unknown[]
|
||||
return (queryKey[0] === 'account' ? profileResult : inviteResult) as ReturnType<typeof useQuery>
|
||||
})
|
||||
}
|
||||
|
||||
describe('NormalForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -104,9 +115,10 @@ describe('NormalForm', () => {
|
||||
it('should send logged-in visitors without a redirect target to the console home', async () => {
|
||||
const searchParams = new URLSearchParams()
|
||||
mockUseSearchParams.mockReturnValue(searchParams)
|
||||
mockUseQuery
|
||||
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
.mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
mockQueryResults(
|
||||
loggedInQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
)
|
||||
|
||||
render(<NormalForm />)
|
||||
|
||||
@ -119,9 +131,10 @@ describe('NormalForm', () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams('redirect_url=https%3A%2F%2Fgoogle.com'),
|
||||
)
|
||||
mockUseQuery
|
||||
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
.mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
mockQueryResults(
|
||||
loggedInQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
)
|
||||
|
||||
render(<NormalForm />)
|
||||
|
||||
@ -134,9 +147,60 @@ describe('NormalForm', () => {
|
||||
describe('Invite Redirects', () => {
|
||||
it('should send logged-in invite visitors to the invite confirmation page', async () => {
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
|
||||
mockUseQuery
|
||||
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
.mockReturnValueOnce(invitationQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
mockQueryResults(
|
||||
loggedInQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
invitationQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
)
|
||||
|
||||
render(<NormalForm />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
'/signin/invite-settings?invite_token=invite-token',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep a different logged-in account on the invitation sign-in form', () => {
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
|
||||
mockQueryResults(
|
||||
{
|
||||
...loggedInQueryResult,
|
||||
data: {
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
email: 'current@example.com',
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof useQuery>,
|
||||
invitationQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
)
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NormalForm />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'login.signBtn' })).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should match the logged-in account email case-insensitively', async () => {
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
|
||||
mockQueryResults(
|
||||
{
|
||||
...loggedInQueryResult,
|
||||
data: {
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
email: 'Invitee@Example.com',
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof useQuery>,
|
||||
invitationQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
)
|
||||
|
||||
render(<NormalForm />)
|
||||
|
||||
@ -153,7 +217,10 @@ describe('NormalForm', () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams('redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing'),
|
||||
)
|
||||
mockUseQuery.mockReturnValue(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
mockQueryResults(
|
||||
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
|
||||
)
|
||||
mockUseSuspenseQuery.mockReturnValue({
|
||||
data: {
|
||||
enable_social_oauth_login: false,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
@ -13,6 +14,7 @@ vi.mock('@tanstack/react-query', async () => {
|
||||
await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
resetQueries: vi.fn(),
|
||||
})),
|
||||
@ -65,6 +67,7 @@ const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
|
||||
const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter>
|
||||
const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams>
|
||||
const mockActivateMember = activateMember as unknown as MockedFunction<typeof activateMember>
|
||||
const mockUseQuery = vi.mocked(useQuery)
|
||||
const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction<
|
||||
typeof useInvitationCheck
|
||||
>
|
||||
@ -96,6 +99,16 @@ describe('InviteSettingsPage', () => {
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useInvitationCheck>)
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: {
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
email: 'invitee@example.com',
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
|
||||
mockActivateMember.mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
@ -260,4 +273,45 @@ describe('InviteSettingsPage', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invitation account guard', () => {
|
||||
it('should redirect a different logged-in account back to the invitation sign-in form', async () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: {
|
||||
profile: {
|
||||
id: 'current-account-id',
|
||||
email: 'current@example.com',
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/signin?invite_token=invite-token')
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'login.join Acme' })).not.toBeInTheDocument()
|
||||
expect(mockActivateMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow case-insensitive email matches', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: {
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
email: 'Invitee@Example.com',
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'login.join Acme' })).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -12,13 +12,14 @@ import {
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiAccountCircleLine } from '@remixicon/react'
|
||||
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { LICENSE_LINK } from '@/constants/link'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { i18n, setLocaleOnClient } from '@/i18n-config'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
@ -30,6 +31,7 @@ import { useInvitationCheck } from '@/service/use-common'
|
||||
import { replaceLoginRedirect } from '@/utils/login-redirect.client'
|
||||
import { getBrowserTimezone, timezones } from '@/utils/timezone'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { isInvitationForAccount } from '../utils/invitation-account'
|
||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||
|
||||
type LanguageSelectOption = {
|
||||
@ -67,6 +69,15 @@ export default function InviteSettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const searchParams = useSearchParams()
|
||||
const token = decodeURIComponent(searchParams.get('invite_token') as string)
|
||||
const {
|
||||
data: userResp,
|
||||
isPending: isProfilePending,
|
||||
error: profileError,
|
||||
} = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
throwOnError: (err) => !isLegacyBase401(err),
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const locale = useLocale()
|
||||
const [name, setName] = useState('')
|
||||
const [isActivating, setIsActivating] = useState(false)
|
||||
@ -92,11 +103,28 @@ export default function InviteSettingsPage() {
|
||||
},
|
||||
}
|
||||
const { data: checkRes, refetch: recheck } = useInvitationCheck(checkParams.params, !!token)
|
||||
const isInvitationForCurrentAccount = isInvitationForAccount(
|
||||
checkRes?.data?.email,
|
||||
userResp?.profile.email,
|
||||
)
|
||||
const shouldReturnToSignIn =
|
||||
!isProfilePending &&
|
||||
Boolean(
|
||||
checkRes?.is_valid &&
|
||||
(isLegacyBase401(profileError) || (userResp && !isInvitationForCurrentAccount)),
|
||||
)
|
||||
const requiresAccountSetup =
|
||||
checkRes?.data?.requires_setup ?? checkRes?.data?.account_status === 'pending'
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldReturnToSignIn) return
|
||||
|
||||
router.replace(`/signin?${searchParams.toString()}`)
|
||||
}, [router, searchParams, shouldReturnToSignIn])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
try {
|
||||
if (!isInvitationForCurrentAccount) return
|
||||
if (requiresAccountSetup && !name) {
|
||||
toast.error(t(($) => $.enterYourName, { ns: 'login' }))
|
||||
return
|
||||
@ -127,7 +155,7 @@ export default function InviteSettingsPage() {
|
||||
setIsActivating(false)
|
||||
}
|
||||
}, [
|
||||
isActivating,
|
||||
isInvitationForCurrentAccount,
|
||||
language,
|
||||
name,
|
||||
queryClient,
|
||||
@ -140,7 +168,7 @@ export default function InviteSettingsPage() {
|
||||
t,
|
||||
])
|
||||
|
||||
if (!checkRes) return <Loading />
|
||||
if (isProfilePending || shouldReturnToSignIn || !checkRes) return <Loading />
|
||||
if (!checkRes.is_valid) {
|
||||
return (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
|
||||
@ -19,6 +19,7 @@ import MailAndPasswordAuth from './components/mail-and-password-auth'
|
||||
import SocialAuth from './components/social-auth'
|
||||
import SSOAuth from './components/sso-auth'
|
||||
import Split from './split'
|
||||
import { isInvitationForAccount } from './utils/invitation-account'
|
||||
import { resolvePostLoginRedirect } from './utils/post-login-redirect'
|
||||
|
||||
type AuthType = 'code' | 'password'
|
||||
@ -67,6 +68,10 @@ function NormalForm() {
|
||||
})
|
||||
|
||||
const workspaceName = invitationCheckResp?.data?.workspace_name || ''
|
||||
const isInvitationForCurrentAccount = isInvitationForAccount(
|
||||
invitationCheckResp?.data?.email,
|
||||
userResp?.profile.email,
|
||||
)
|
||||
const hasSocialLogin = systemFeatures.enable_social_oauth_login
|
||||
const hasSsoLogin = Boolean(systemFeatures.sso_enforced_for_signin)
|
||||
const hasEmailCodeLogin = systemFeatures.enable_email_code_login
|
||||
@ -83,18 +88,21 @@ function NormalForm() {
|
||||
const noLoginMethodsConfigured =
|
||||
!hasSocialLogin && !hasEmailCodeLogin && !hasEmailPasswordLogin && !hasSsoLogin
|
||||
const allMethodsAreDisabled = noLoginMethodsConfigured || isInviteCheckError
|
||||
const isLoading = isCheckLoading || isLoggedIn || (isInviteLink && isInviteCheckLoading)
|
||||
const shouldRedirectLoggedInUser = isLoggedIn && (!isInviteLink || isInvitationForCurrentAccount)
|
||||
const isLoading =
|
||||
isCheckLoading || shouldRedirectLoggedInUser || (isInviteLink && isInviteCheckLoading)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
if (isInviteLink) {
|
||||
if (!isInvitationForCurrentAccount) return
|
||||
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
|
||||
return
|
||||
}
|
||||
|
||||
replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath)
|
||||
}, [isInviteLink, isLoggedIn, router, searchParams])
|
||||
}, [isInvitationForCurrentAccount, isInviteLink, isLoggedIn, router, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (message) toast.error(message)
|
||||
|
||||
9
web/app/signin/utils/invitation-account.ts
Normal file
9
web/app/signin/utils/invitation-account.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const isInvitationForAccount = (
|
||||
invitationEmail?: string | null,
|
||||
accountEmail?: string | null,
|
||||
) =>
|
||||
Boolean(
|
||||
invitationEmail &&
|
||||
accountEmail &&
|
||||
invitationEmail.trim().toLowerCase() === accountEmail.trim().toLowerCase(),
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user