mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-24 11:07:14 +00:00
fix(auth): enable local registration for OSS invitations (#2460)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
if cloud_mode:
|
if cloud_mode:
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||||
|
capabilities['invitation_registration_enabled'] = not 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)
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': False,
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': True,
|
'authenticated_invitation_acceptance_enabled': True,
|
||||||
|
'invitation_registration_enabled': False,
|
||||||
|
'password_login_enabled': False,
|
||||||
|
'space_login_enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||||
|
self, quart_test_client, fake_api_app
|
||||||
|
):
|
||||||
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||||
|
return_value={'password_login_enabled': False, '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': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': False,
|
'password_login_enabled': False,
|
||||||
'space_login_enabled': True,
|
'space_login_enabled': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.user_service.get_workspace_owner = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||||
|
)
|
||||||
|
application.space_service.get_credits = AsyncMock()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space-credits',
|
||||||
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
|
)
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['data'] == {
|
||||||
|
'credits': None,
|
||||||
|
'owner_space_bound': False,
|
||||||
|
'is_workspace_owner': True,
|
||||||
|
}
|
||||||
|
application.space_service.get_credits.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|||||||
@@ -1181,6 +1181,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
public getAccountInfo(): Promise<{
|
public getAccountInfo(): Promise<{
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
authenticated_invitation_acceptance_enabled?: boolean;
|
authenticated_invitation_acceptance_enabled?: boolean;
|
||||||
|
invitation_registration_enabled?: boolean;
|
||||||
password_login_enabled?: boolean;
|
password_login_enabled?: boolean;
|
||||||
space_login_enabled?: boolean;
|
space_login_enabled?: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
|
||||||
|
useState(false);
|
||||||
|
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [
|
const [
|
||||||
authenticatedInvitationAcceptanceEnabled,
|
authenticatedInvitationAcceptanceEnabled,
|
||||||
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
|
|||||||
backendClient
|
backendClient
|
||||||
.getAccountInfo()
|
.getAccountInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
setInvitationRegistrationEnabled(
|
||||||
|
info.invitation_registration_enabled ??
|
||||||
|
info.password_login_enabled !== false,
|
||||||
|
);
|
||||||
setAuthenticatedInvitationAcceptanceEnabled(
|
setAuthenticatedInvitationAcceptanceEnabled(
|
||||||
info.authenticated_invitation_acceptance_enabled === true,
|
info.authenticated_invitation_acceptance_enabled === true,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => setPasswordRegistrationEnabled(false));
|
.catch(() => setInvitationRegistrationEnabled(false))
|
||||||
|
.finally(() => setInvitationCapabilitiesLoaded(true));
|
||||||
if (!invitationToken) {
|
if (!invitationToken) {
|
||||||
setErrorMessage(t('workspace.invitationMissing'));
|
setErrorMessage(t('workspace.invitationMissing'));
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
{!invitationCapabilitiesLoaded ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
|
|||||||
{t('workspace.logoutAndReturn')}
|
{t('workspace.logoutAndReturn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : passwordRegistrationEnabled ? (
|
) : invitationRegistrationEnabled ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
if (path === '/api/v1/user/account-info') {
|
if (path === '/api/v1/user/account-info') {
|
||||||
return fulfillJson(route, {
|
return fulfillJson(route, {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: false,
|
||||||
|
invitation_registration_enabled: true,
|
||||||
password_login_enabled: true,
|
password_login_enabled: true,
|
||||||
space_login_enabled: false,
|
space_login_enabled: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,6 +107,101 @@ test('login preserves an explicit invitation email mismatch error', async ({
|
|||||||
await expect(page.getByText('Login successful')).toHaveCount(0);
|
await expect(page.getByText('Login successful')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an OAuth-only OSS instance registers the invited email with a local password', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let registration: { email?: string; password?: string } | undefined;
|
||||||
|
await installLangBotApiMocks(page, { authenticated: false });
|
||||||
|
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: false,
|
||||||
|
invitation_registration_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: 'oss-local-registration',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
normalized_email: 'invited@example.com',
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
uuid: 'workspace-playwright',
|
||||||
|
name: 'Playwright Workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||||
|
const body = JSON.parse(route.request().postData() || '{}') as {
|
||||||
|
registration?: { email?: string; password?: string };
|
||||||
|
};
|
||||||
|
registration = body.registration;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
login_required: true,
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/invitations/accept#token=oss-local-registration');
|
||||||
|
|
||||||
|
await expect(page.getByText('Playwright Workspace')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with LangBot Account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.locator('#invite-email')).toHaveValue(
|
||||||
|
'invited@example.com',
|
||||||
|
);
|
||||||
|
await expect(page.locator('#invite-email')).toHaveAttribute('readonly', '');
|
||||||
|
await expect(page.locator('#invite-password')).toBeVisible();
|
||||||
|
await expect(page.locator('#invite-password-confirm')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Create account and accept' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with LangBot Account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('#invite-password').fill('invite-password-123');
|
||||||
|
await page.locator('#invite-password-confirm').fill('invite-password-123');
|
||||||
|
await page.getByRole('button', { name: 'Create account and accept' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login\?invitation=1$/);
|
||||||
|
expect(registration).toEqual({
|
||||||
|
email: 'invited@example.com',
|
||||||
|
password: 'invite-password-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('an authenticated OSS invitation requires logout before registration', async ({
|
test('an authenticated OSS invitation requires logout before registration', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -185,6 +280,7 @@ test('an authenticated Cloud Account can accept its invitation directly', async
|
|||||||
data: {
|
data: {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
authenticated_invitation_acceptance_enabled: true,
|
authenticated_invitation_acceptance_enabled: true,
|
||||||
|
invitation_registration_enabled: false,
|
||||||
password_login_enabled: false,
|
password_login_enabled: false,
|
||||||
space_login_enabled: true,
|
space_login_enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
await page.route('**/api/v1/user/space-credits', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
credits: null,
|
||||||
|
owner_space_bound: false,
|
||||||
|
is_workspace_owner: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/providers', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
uuid: 'langbot-models-provider',
|
||||||
|
name: 'LangBot Models',
|
||||||
|
requester: 'space-chat-completions',
|
||||||
|
base_url: '',
|
||||||
|
api_keys: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/requesters**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/models/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/home?action=showModelSettings');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user