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
10 changes: 10 additions & 0 deletions foundations/core/packages/account-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export interface AccountClient {
signUp: (email: string, password: string, first: string, last: string) => Promise<LoginInfo>
login: (email: string, password: string) => Promise<LoginInfo>
loginAsGuest: () => Promise<LoginInfo>
getLoginCapabilities: () => Promise<{ signUpEnabled: boolean, guestLoginAvailable: boolean }>
isReadOnlyGuest: () => Promise<boolean>
getPerson: () => Promise<Person>
getPersonInfo: (account: PersonUuid) => Promise<PersonInfo>
Expand Down Expand Up @@ -722,6 +723,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}

async getLoginCapabilities (): Promise<{ signUpEnabled: boolean, guestLoginAvailable: boolean }> {
const request = {
method: 'getLoginCapabilities' as const,
params: {}
}

return await this.rpc(request)
}

async isReadOnlyGuest (): Promise<boolean> {
const request = {
method: 'isReadOnlyGuest' as const,
Expand Down
38 changes: 33 additions & 5 deletions plugins/login-resources/src/components/LoginForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import BottomActionComponent from './BottomAction.svelte'
import login from '../plugin'
import { LoginInfo } from '@hcengineering/account-client'
import { fetchLoginCapabilities } from '../utils'

export let navigateUrl: string | undefined = undefined
export let signUpDisabled = false
Expand All @@ -37,9 +38,18 @@
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined

let method: LoginMethods = useOTP ? LoginMethods.Otp : LoginMethods.Password
let guestLoginAvailable = false
let capabilitiesLoaded = false
let signUpEnabled = true
$: effectiveSignUpDisabled = signUpDisabled || (capabilitiesLoaded && !signUpEnabled)

onMount(() => {
signupStore.setSignUpFlow(false)
void fetchLoginCapabilities().then((capabilities) => {
signUpEnabled = capabilities.signUpEnabled
guestLoginAvailable = capabilities.guestLoginAvailable
capabilitiesLoaded = true
})
})

function changeMethod (event: CustomEvent<LoginMethods>): void {
Expand Down Expand Up @@ -87,15 +97,33 @@
</script>

{#if method === LoginMethods.Otp}
<LoginOtpForm {navigateUrl} {signUpDisabled} {email} {caption} {subtitle} {onLogin} on:change={changeMethod} />
<LoginOtpForm
{navigateUrl}
signUpDisabled={effectiveSignUpDisabled}
{email}
{caption}
{subtitle}
{onLogin}
on:change={changeMethod}
/>
{:else}
<LoginPasswordForm {navigateUrl} {signUpDisabled} {email} {caption} {subtitle} {onLogin} on:change={changeMethod} />
<LoginPasswordForm
{navigateUrl}
signUpDisabled={effectiveSignUpDisabled}
{email}
{caption}
{subtitle}
{onLogin}
on:change={changeMethod}
/>
{/if}
<div class="actions" style:margin-inline-start={loginFormPaddingInline($deviceInfo.docWidth, $deviceInfo.docHeight)}>
<BottomActionComponent action={method === LoginMethods.Otp ? loginWithPasswordAction : loginWithCodeAction} />
<div class="login-as-guest">
<BottomActionComponent action={loginAsGuest} />
</div>
{#if guestLoginAvailable}
<div class="login-as-guest">
<BottomActionComponent action={loginAsGuest} />
</div>
{/if}
</div>

<style lang="scss">
Expand Down
19 changes: 19 additions & 0 deletions plugins/login-resources/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ export function getAccountClient (
return getAccountClientRaw(accountsUrl, token !== null ? token : undefined)
}

/**
* Queries the account service for login affordance flags. Used by the login UI
* to hide the Sign Up tab and "Continue as guest" button when those flows are
* disabled server-side. Falls back to both available on any error to preserve
* backwards compatibility with account services that do not implement this
* endpoint yet.
*/
export async function fetchLoginCapabilities (): Promise<{
signUpEnabled: boolean
guestLoginAvailable: boolean
}> {
try {
const accountClient = getAccountClient(null)
return await accountClient.getLoginCapabilities()
} catch {
return { signUpEnabled: true, guestLoginAvailable: true }
}
}

/**
* Perform a login operation to required workspace with user credentials.
*/
Expand Down
170 changes: 170 additions & 0 deletions server/account/src/__tests__/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
getLoginInfoByToken,
releaseSocialId,
loginAsGuest,
getLoginCapabilities,
resetGuestPersonCache,
loginOtp,
login,
confirm,
Expand Down Expand Up @@ -1562,6 +1564,129 @@ describe('account operations', () => {
new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
)
})

test('should fail with Forbidden when DISABLE_GUEST_LOGIN=true', async () => {
const prev = process.env.DISABLE_GUEST_LOGIN
process.env.DISABLE_GUEST_LOGIN = 'true'
try {
await expect(loginAsGuest(mockCtx, mockDb, mockBranding, mockToken)).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
)
expect(mockDb.person.findOne).not.toHaveBeenCalled()
} finally {
if (prev === undefined) {
delete process.env.DISABLE_GUEST_LOGIN
} else {
process.env.DISABLE_GUEST_LOGIN = prev
}
}
})
})

describe('getLoginCapabilities', () => {
// L-AUTH-1: the guest-person lookup is cached module-wide; reset before
// each test so per-test mock return values are observed deterministically.
beforeEach(() => {
resetGuestPersonCache()
})
const restoreEnv = (key: string, prev: string | undefined): void => {
if (prev === undefined) {
Reflect.deleteProperty(process.env, key)
} else {
process.env[key] = prev
}
}

test('should report both signUp and guest available by default when guest person exists', async () => {
const prevSignup = process.env.DISABLE_SIGNUP
const prevGuest = process.env.DISABLE_GUEST_LOGIN
delete process.env.DISABLE_SIGNUP
delete process.env.DISABLE_GUEST_LOGIN
try {
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({ uuid: readOnlyGuestAccountUuid })

const result = await getLoginCapabilities(mockCtx, mockDb, mockBranding, mockToken)

expect(result).toEqual({ signUpEnabled: true, guestLoginAvailable: true })
} finally {
restoreEnv('DISABLE_SIGNUP', prevSignup)
restoreEnv('DISABLE_GUEST_LOGIN', prevGuest)
}
})

test('should report guestLoginAvailable=false when guest person missing', async () => {
const prevSignup = process.env.DISABLE_SIGNUP
const prevGuest = process.env.DISABLE_GUEST_LOGIN
delete process.env.DISABLE_SIGNUP
delete process.env.DISABLE_GUEST_LOGIN
try {
;(mockDb.person.findOne as jest.Mock).mockResolvedValue(null)

const result = await getLoginCapabilities(mockCtx, mockDb, mockBranding, mockToken)

expect(result).toEqual({ signUpEnabled: true, guestLoginAvailable: false })
} finally {
restoreEnv('DISABLE_SIGNUP', prevSignup)
restoreEnv('DISABLE_GUEST_LOGIN', prevGuest)
}
})

test('should report signUpEnabled=false when DISABLE_SIGNUP=true', async () => {
const prevSignup = process.env.DISABLE_SIGNUP
const prevGuest = process.env.DISABLE_GUEST_LOGIN
process.env.DISABLE_SIGNUP = 'true'
delete process.env.DISABLE_GUEST_LOGIN
try {
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({ uuid: readOnlyGuestAccountUuid })

const result = await getLoginCapabilities(mockCtx, mockDb, mockBranding, mockToken)

expect(result).toEqual({ signUpEnabled: false, guestLoginAvailable: true })
} finally {
restoreEnv('DISABLE_SIGNUP', prevSignup)
restoreEnv('DISABLE_GUEST_LOGIN', prevGuest)
}
})

test('should report guestLoginAvailable=false when DISABLE_GUEST_LOGIN=true (no DB lookup)', async () => {
const prevSignup = process.env.DISABLE_SIGNUP
const prevGuest = process.env.DISABLE_GUEST_LOGIN
delete process.env.DISABLE_SIGNUP
process.env.DISABLE_GUEST_LOGIN = 'true'
try {
;(mockDb.person.findOne as jest.Mock).mockClear()

const result = await getLoginCapabilities(mockCtx, mockDb, mockBranding, mockToken)

expect(result).toEqual({ signUpEnabled: true, guestLoginAvailable: false })
expect(mockDb.person.findOne).not.toHaveBeenCalled()
} finally {
restoreEnv('DISABLE_SIGNUP', prevSignup)
restoreEnv('DISABLE_GUEST_LOGIN', prevGuest)
}
})

test('L-AUTH-1: caches the guest-person lookup within TTL (single DB hit)', async () => {
const prevSignup = process.env.DISABLE_SIGNUP
const prevGuest = process.env.DISABLE_GUEST_LOGIN
delete process.env.DISABLE_SIGNUP
delete process.env.DISABLE_GUEST_LOGIN
try {
;(mockDb.person.findOne as jest.Mock).mockClear()
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({ uuid: readOnlyGuestAccountUuid })

const r1 = await getLoginCapabilities(mockCtx, mockDb, mockBranding, mockToken)
const r2 = await getLoginCapabilities(mockCtx, mockDb, mockBranding, mockToken)

expect(r1).toEqual({ signUpEnabled: true, guestLoginAvailable: true })
expect(r2).toEqual({ signUpEnabled: true, guestLoginAvailable: true })
// Second call within TTL must be served from cache -> exactly one DB hit.
expect(mockDb.person.findOne).toHaveBeenCalledTimes(1)
} finally {
restoreEnv('DISABLE_SIGNUP', prevSignup)
restoreEnv('DISABLE_GUEST_LOGIN', prevGuest)
}
})
})
})

Expand Down Expand Up @@ -1798,6 +1923,29 @@ describe('account operations', () => {
)
}
})

test('should fail with Forbidden when DISABLE_SIGNUP=true', async () => {
const prev = process.env.DISABLE_SIGNUP
process.env.DISABLE_SIGNUP = 'true'
const signUpByEmailSpy = jest.spyOn(utils, 'signUpByEmail')
try {
await expect(
signUp(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
password: mockPassword,
firstName: mockFirstName,
lastName: mockLastName
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
expect(signUpByEmailSpy).not.toHaveBeenCalled()
} finally {
if (prev === undefined) {
delete process.env.DISABLE_SIGNUP
} else {
process.env.DISABLE_SIGNUP = prev
}
}
})
})

describe('confirm', () => {
Expand Down Expand Up @@ -2022,6 +2170,28 @@ describe('account operations', () => {
)
}
})

test('should fail with Forbidden when DISABLE_SIGNUP=true', async () => {
const prev = process.env.DISABLE_SIGNUP
process.env.DISABLE_SIGNUP = 'true'
const getEmailSocialIdSpy = jest.spyOn(utils, 'getEmailSocialId')
try {
await expect(
signUpOtp(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
firstName: mockFirstName,
lastName: mockLastName
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
expect(getEmailSocialIdSpy).not.toHaveBeenCalled()
} finally {
if (prev === undefined) {
delete process.env.DISABLE_SIGNUP
} else {
process.env.DISABLE_SIGNUP = prev
}
}
})
})

describe('validateOtp', () => {
Expand Down
Loading