mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
fix(cloud): accept invitations with current account (#2382)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -311,8 +311,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.success(data={'initialized': False})
|
return self.success(data={'initialized': False})
|
||||||
|
|
||||||
capabilities = await self.ap.user_service.get_login_capabilities()
|
capabilities = await self.ap.user_service.get_login_capabilities()
|
||||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||||
|
if cloud_mode:
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
|
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||||
return self.success(data={'initialized': True, **capabilities})
|
return self.success(data={'initialized': True, **capabilities})
|
||||||
|
|
||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||||
|
|
||||||
@@ -304,12 +306,34 @@ class TestUserInitEndpoint:
|
|||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
||||||
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud(
|
||||||
|
self, quart_test_client, fake_api_app
|
||||||
|
):
|
||||||
|
fake_api_app.deployment = SimpleNamespace(mode='cloud')
|
||||||
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||||
|
return_value={'password_login_enabled': True, 'space_login_enabled': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data['data'] == {
|
||||||
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': True,
|
||||||
|
'password_login_enabled': False,
|
||||||
|
'space_login_enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
||||||
fake_api_app.user_service.is_initialized.return_value = True
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
|||||||
@@ -1179,6 +1179,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
|
|
||||||
public getAccountInfo(): Promise<{
|
public getAccountInfo(): Promise<{
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
|
authenticated_invitation_acceptance_enabled?: boolean;
|
||||||
password_login_enabled?: boolean;
|
password_login_enabled?: boolean;
|
||||||
space_login_enabled?: boolean;
|
space_login_enabled?: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export default function AcceptInvitationPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [
|
||||||
|
authenticatedInvitationAcceptanceEnabled,
|
||||||
|
setAuthenticatedInvitationAcceptanceEnabled,
|
||||||
|
] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleHashChange = () => setInvitationHash(window.location.hash);
|
const handleHashChange = () => setInvitationHash(window.location.hash);
|
||||||
@@ -113,6 +117,9 @@ export default function AcceptInvitationPage() {
|
|||||||
.getAccountInfo()
|
.getAccountInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||||
|
setAuthenticatedInvitationAcceptanceEnabled(
|
||||||
|
info.authenticated_invitation_acceptance_enabled === true,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => setPasswordRegistrationEnabled(false));
|
.catch(() => setPasswordRegistrationEnabled(false));
|
||||||
if (!invitationToken) {
|
if (!invitationToken) {
|
||||||
@@ -304,7 +311,18 @@ export default function AcceptInvitationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLoginToken ? (
|
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
onClick={() => void finishAcceptance()}
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t('workspace.acceptInvitation')}
|
||||||
|
</Button>
|
||||||
|
) : hasLoginToken ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
{t('workspace.authenticatedInvitationNotice')}
|
{t('workspace.authenticatedInvitationNotice')}
|
||||||
|
|||||||
@@ -166,6 +166,83 @@ test('an authenticated OSS invitation requires logout before registration', asyn
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an authenticated Cloud Account can accept its invitation directly', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
storage: {
|
||||||
|
token: 'invited-account-token',
|
||||||
|
userEmail: 'invited@example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: true,
|
||||||
|
password_login_enabled: false,
|
||||||
|
space_login_enabled: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
invitation: {
|
||||||
|
uuid: 'cloud-invitation',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
normalized_email: 'invited@example.com',
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
uuid: 'workspace-playwright',
|
||||||
|
name: 'Playwright Workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let acceptanceAuthorization = '';
|
||||||
|
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||||
|
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
token: 'accepted-cloud-account-token',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/invitations/accept#token=cloud-invitation');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Accept Invitation' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Accept Invitation' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||||
|
expect(acceptanceAuthorization).toBe('Bearer invited-account-token');
|
||||||
|
});
|
||||||
|
|
||||||
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
|
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user