Skip to content
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