fix(cloud): accept invitations with current account

This commit is contained in:
dadachann
2026-08-02 09:08:49 +00:00
parent a67728c163
commit a7a7218afe
5 changed files with 124 additions and 2 deletions
@@ -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)
+24
View File
@@ -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
+1
View File
@@ -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;
}> { }> {
+19 -1
View File
@@ -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')}
+77
View File
@@ -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,
}) => { }) => {