diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index d6f2f4b9c..5b579cd7a 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -75,6 +75,8 @@ class UserRouterGroup(group.RouterGroup): @self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: + if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud') json_data = await quart.request.json try: @@ -293,7 +295,7 @@ class UserRouterGroup(group.RouterGroup): # Login is selected per account in a multi-user instance. A public # bootstrap endpoint must never project one user's authentication # methods onto every other user or disclose that user's state. - 'password_login_enabled': True, + 'password_login_enabled': getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud', 'space_login_enabled': True, } ) diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 6aecc9d04..6390777c6 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -329,6 +329,12 @@ class InvitationsRouterGroup(group.RouterGroup): return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid}) registration = data.get('registration') + if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + return self.http_status( + 401, + 'account_exists_login_required', + 'Login with your LangBot Account to accept this invitation', + ) if not isinstance(registration, dict): return self.http_status( 401, diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 223bc1e67..333c657cb 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -212,6 +212,12 @@ class Application: name='cloud-manifest-refresh', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + if self.workspace_collaboration_service is not None: + self.task_mgr.create_task( + self.workspace_collaboration_service.run_expired_invitation_cleanup(), + name='workspace-invitation-cleanup', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) await self.plugin_connector.initialize_plugins() diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index b313d4222..cccfc26e0 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -552,6 +552,47 @@ class WorkspaceCollaborationService: return await self._run(operation, session=session) + async def cleanup_expired_invitations( + self, + *, + retention: datetime.timedelta = datetime.timedelta(0), + ) -> int: + """Delete expired invitation records without crossing Cloud tenant scopes.""" + cutoff = self._utcnow() - retention + + async def cleanup_session(active_session: AsyncSession, workspace_uuid: str | None = None) -> int: + statement = sqlalchemy.delete(WorkspaceInvitation).where( + WorkspaceInvitation.status.in_((InvitationStatus.PENDING.value, InvitationStatus.EXPIRED.value)), + WorkspaceInvitation.expires_at <= cutoff, + ) + if workspace_uuid is not None: + statement = statement.where(WorkspaceInvitation.workspace_uuid == workspace_uuid) + result = await active_session.execute(statement) + return int(result.rowcount or 0) + + if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime': + list_bindings = getattr(self.workspace_service, 'list_active_execution_bindings', None) + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if not callable(list_bindings) or not callable(tenant_uow): + raise RuntimeError('Cloud invitation cleanup requires tenant units of work') + deleted = 0 + for binding in await list_bindings(): + async with tenant_uow(binding.workspace_uuid) as uow: + deleted += await cleanup_session(uow.session, binding.workspace_uuid) + return deleted + return await self._run(cleanup_session, session=None) + + async def run_expired_invitation_cleanup(self, *, interval_seconds: float = 3600) -> None: + """Periodically remove expired records, waiting first so expiry inspection wins.""" + while True: + await asyncio.sleep(interval_seconds) + try: + await self.cleanup_expired_invitations() + except asyncio.CancelledError: + raise + except Exception: + self.ap.logger.exception('Expired Workspace invitation cleanup failed') + async def update_member_role( self, workspace_uuid: str, diff --git a/src/langbot/pkg/workspace/invitation_delivery.py b/src/langbot/pkg/workspace/invitation_delivery.py index 68972360d..42decbf6f 100644 --- a/src/langbot/pkg/workspace/invitation_delivery.py +++ b/src/langbot/pkg/workspace/invitation_delivery.py @@ -234,22 +234,51 @@ class InvitationDeliveryService: @staticmethod def _plain_text(workspace_name: str, invitation_link: str) -> str: return ( - f'You were invited to join {workspace_name} on LangBot.\n\n' - f'Open this secure invitation link to continue:\n{invitation_link}\n' + 'You have been invited to LangBot Cloud\n\n' + f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n' + f'Accept invitation: {invitation_link}\n\n' + 'This secure invitation expires in 7 days and can only be accepted by the email address ' + 'it was sent to. If you were not expecting it, you can safely ignore this email.\n' ) @staticmethod def _html(workspace_name: str, invitation_link: str) -> str: - escaped_workspace = workspace_name.replace('&', '&').replace('<', '<').replace('>', '>') - escaped_link = ( - invitation_link.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') - ) - return ( - '

You were invited to join ' - f'{escaped_workspace} on LangBot.

' - f'

Accept the invitation

' - f'

{escaped_link}

' - ) + import html + + escaped_workspace = html.escape(workspace_name, quote=True) + escaped_link = html.escape(invitation_link, quote=True) + return f''' + + + + + Join {escaped_workspace} on LangBot Cloud + + +
You have been invited to join {escaped_workspace} on LangBot Cloud.
+ + +
+ + + + +
+
LangBot Cloud
+
You’re invited
+
+

You have been invited to collaborate in this Workspace:

+
{escaped_workspace}
+
+ Accept invitation +
+

This invitation expires in 7 days and is bound to the email address that received it.

+

If the button does not work, copy and paste this URL into your browser:

+

{escaped_link}

+
If you were not expecting this invitation, you can safely ignore this email.
+
+ +''' @staticmethod def _number(value: typing.Any, default: float) -> float: diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py index cd45c21d1..34a7f5512 100644 --- a/tests/unit_tests/workspace/test_invitation_delivery.py +++ b/tests/unit_tests/workspace/test_invitation_delivery.py @@ -86,3 +86,20 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke 'https://env.langbot.example/invitations/accept#token=lbi_secret' ) assert service.capability() == {'enabled': True, 'provider': 'smtp'} + + +async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy(): + service = InvitationDeliveryService(_app({})) + link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=' + + text = service._plain_text('Research & Development', link) + html = service._html('Research & Development', link) + + assert 'LangBot Cloud' in text + assert 'Research & Development' in text + assert '7 days' in text + assert link in text + assert 'Accept invitation' in html + assert 'Research & Development' in html + assert 'expires in 7 days' in html + assert 'lbi_secret&next=<unsafe>' in html diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index a5fbb7bca..b11f1975c 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import datetime import uuid from contextlib import asynccontextmanager from types import SimpleNamespace @@ -19,6 +20,7 @@ from langbot.pkg.entity.persistence.workspace import ( ) from langbot.pkg.workspace.collaboration import ( InvitationEmailMismatchError, + InvitationExpiredError, InvitationRoleError, InvitationUsedError, LastOwnerError, @@ -159,6 +161,23 @@ async def test_invitation_rejects_owner_role_and_email_mismatch(collaboration_co await service.accept_invitation(created.token, wrong_account.uuid) +async def test_expired_invitation_is_inspectable_before_periodic_cleanup_deletes_it(collaboration_context): + service, _, session_factory, _, workspace, owner_membership = collaboration_context + created = await service.create_invitation( + workspace.uuid, owner_membership, 'expired@example.com', 'viewer' + ) + async with session_factory.begin() as session: + invitation = await session.get(WorkspaceInvitation, created.invitation.uuid) + invitation.expires_at = service._utcnow() - datetime.timedelta(minutes=1) + + with pytest.raises(InvitationExpiredError): + await service.inspect_invitation(created.token) + + assert await service.cleanup_expired_invitations(retention=datetime.timedelta(0)) == 1 + async with session_factory() as session: + assert await session.get(WorkspaceInvitation, created.invitation.uuid) is None + + async def test_last_owner_cannot_be_demoted(collaboration_context): service, _, session_factory, _, workspace, owner_membership = collaboration_context created = await service.create_invitation( diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index e37fc55d8..2884fd951 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -4,6 +4,7 @@ import { httpClient } from '@/app/infra/http/HttpClient'; import { beginAuthenticatedSession, bootstrapWorkspaceSession, + getPendingInvitationToken, } from '@/app/infra/http'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -91,6 +92,10 @@ function SpaceOAuthCallbackContent() { } beginAuthenticatedSession(response.token, response.user); + if (getPendingInvitationToken()) { + navigate('/invitations/accept', { replace: true }); + return; + } const workspaceResult = await bootstrapWorkspaceSession({ preferredWorkspaceUuid: response.workspace_uuid, }); diff --git a/web/src/app/invitations/accept/page.tsx b/web/src/app/invitations/accept/page.tsx index 21df23940..20eb93fa5 100644 --- a/web/src/app/invitations/accept/page.tsx +++ b/web/src/app/invitations/accept/page.tsx @@ -92,6 +92,7 @@ export default function AcceptInvitationPage() { const [errorMessage, setErrorMessage] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(false); useEffect(() => { const handleHashChange = () => setInvitationHash(window.location.hash); @@ -108,6 +109,9 @@ export default function AcceptInvitationPage() { 'error', ); setToken(invitationToken); + backendClient.getAccountInfo().then((info) => { + setPasswordRegistrationEnabled(info.password_login_enabled !== false); + }).catch(() => setPasswordRegistrationEnabled(false)); if (!invitationToken) { setErrorMessage(t('workspace.invitationMissing')); setStatus('error'); @@ -210,6 +214,15 @@ export default function AcceptInvitationPage() { }); } + function switchAccount() { + clearUserInfo(); + if (typeof window !== 'undefined') { + localStorage.removeItem('token'); + localStorage.removeItem('userEmail'); + } + navigate('/login?invitation=1&auto=space', { replace: true }); + } + function returnToLogin() { clearUserInfo(); if (typeof window !== 'undefined') { @@ -221,6 +234,11 @@ export default function AcceptInvitationPage() { const hasLoginToken = typeof window !== 'undefined' && Boolean(localStorage.getItem('token')); + const currentEmail = + typeof window !== 'undefined' ? localStorage.getItem('userEmail') : null; + const currentAccountMatches = + currentEmail?.trim().toLocaleLowerCase() === + view?.invitation.normalized_email.toLocaleLowerCase(); return (
@@ -285,7 +303,7 @@ export default function AcceptInvitationPage() {
)} - {hasLoginToken ? ( + {hasLoginToken && currentAccountMatches ? ( + + ) : passwordRegistrationEnabled ? ( <>
)} diff --git a/web/src/app/register/page.tsx b/web/src/app/register/page.tsx index 04f3e4fb5..fb155ac56 100644 --- a/web/src/app/register/page.tsx +++ b/web/src/app/register/page.tsx @@ -44,6 +44,7 @@ export default function Register() { const navigate = useNavigate(); const { t } = useTranslation(); const [spaceLoading, setSpaceLoading] = useState(false); + const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(true); const form = useForm>>({ resolver: zodResolver(formSchema(t)), @@ -55,6 +56,10 @@ export default function Register() { useEffect(() => { getIsInitialized(); + httpClient + .getAccountInfo() + .then((info) => setPasswordRegistrationEnabled(info.password_login_enabled !== false)) + .catch(() => setPasswordRegistrationEnabled(true)); }, []); function getIsInitialized() { @@ -159,20 +164,22 @@ export default function Register() {

-
-
- -
-
- - {t('common.or')} - -
-
+ {passwordRegistrationEnabled && ( + <> +
+
+ +
+
+ + {t('common.or')} + +
+
- {/* Local Account Registration */} -
- + {/* Local Account Registration */} + + {t('register.registerWithPassword')} - - + + + + )}

{t('common.agreementNotice')}{' '} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index e635f5f3d..85fa8206e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -80,7 +80,7 @@ const enUS = { loading: 'Loading...', fieldRequired: 'This field is required', or: 'or', - loginWithSpace: 'Login with Space', + loginWithSpace: 'Login with LangBot Account', spaceLoginRecommended: 'Recommended: Use official stable model APIs and cloud services', loginLocal: 'Login with local account', @@ -277,7 +277,7 @@ const enUS = { searchProviders: 'Search providers...', langbotModelsDescription: 'Cloud models powered by LangBot Space', credits: 'Credits', - loginWithSpace: 'Login with Space', + loginWithSpace: 'Login with LangBot Account', loginToUseModels: 'Login with Space to use cloud models', noModels: 'No models configured', langbotModels: 'LangBot Models', @@ -1339,6 +1339,7 @@ const enUS = { existingAccountLoginRequired: 'An account already exists for this email. Sign in to continue.', acceptAsCurrentAccount: 'Accept with current account', + switchAccount: 'Switch account', registerAndAccept: 'Create account and accept', alreadyHaveAccount: 'I already have an account', confirmPassword: 'Confirm password', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 90ca4e3bd..0c1879dcd 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -83,7 +83,7 @@ const esES = { loading: 'Cargando...', fieldRequired: 'Este campo es obligatorio', or: 'o', - loginWithSpace: 'Iniciar sesión con Space', + loginWithSpace: 'Iniciar sesión con una cuenta de LangBot', spaceLoginRecommended: 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube', loginLocal: 'Iniciar sesión con cuenta local', @@ -286,7 +286,7 @@ const esES = { searchProviders: 'Buscar proveedores...', langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space', credits: 'Créditos', - loginWithSpace: 'Iniciar sesión con Space', + loginWithSpace: 'Iniciar sesión con una cuenta de LangBot', loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube', noModels: 'No hay modelos configurados', langbotModels: 'Modelos LangBot', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index a0b816347..def29b657 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -81,7 +81,7 @@ const jaJP = { loading: '読み込み中...', fieldRequired: 'この項目は必須です', or: 'または', - loginWithSpace: 'Space でログイン', + loginWithSpace: 'LangBot アカウントでログイン', spaceLoginRecommended: 'おすすめ:公式の安定したモデル API とクラウドサービスを利用', loginLocal: 'ローカルアカウントでログイン', @@ -282,7 +282,7 @@ const jaJP = { searchProviders: 'プロバイダーを検索...', langbotModelsDescription: 'LangBot Space が提供するクラウドモデル', credits: 'クレジット', - loginWithSpace: 'Space でログイン', + loginWithSpace: 'LangBot アカウントでログイン', loginToUseModels: 'Space でログインしてクラウドモデルを使用', noModels: 'モデルがありません', langbotModels: 'LangBot モデル', @@ -1340,6 +1340,7 @@ const jaJP = { existingAccountLoginRequired: 'このメールアドレスのアカウントは既に存在します。ログインしてください。', acceptAsCurrentAccount: '現在のアカウントで承認', + switchAccount: 'アカウントを切り替える', registerAndAccept: 'アカウントを作成して承認', alreadyHaveAccount: 'アカウントを持っています', confirmPassword: 'パスワードを確認', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index e74176307..c081fefbf 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -80,7 +80,7 @@ const ruRU = { loading: 'Загрузка...', fieldRequired: 'Это поле обязательно для заполнения', or: 'или', - loginWithSpace: 'Войти через Space', + loginWithSpace: 'Войти с аккаунтом LangBot', spaceLoginRecommended: 'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы', loginLocal: 'Войти с локальной учётной записью', @@ -284,7 +284,7 @@ const ruRU = { searchProviders: 'Поиск провайдеров...', langbotModelsDescription: 'Облачные модели на базе LangBot Space', credits: 'Кредиты', - loginWithSpace: 'Войти через Space', + loginWithSpace: 'Войти с аккаунтом LangBot', loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели', noModels: 'Модели не настроены', langbotModels: 'Модели LangBot', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 1a864226f..3af8ad285 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -80,7 +80,7 @@ const thTH = { loading: 'กำลังโหลด...', fieldRequired: 'ช่องนี้จำเป็นต้องกรอก', or: 'หรือ', - loginWithSpace: 'เข้าสู่ระบบด้วย Space', + loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot', spaceLoginRecommended: 'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์', loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น', @@ -273,7 +273,7 @@ const thTH = { searchProviders: 'ค้นหาผู้ให้บริการ...', langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space', credits: 'เครดิต', - loginWithSpace: 'เข้าสู่ระบบด้วย Space', + loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot', loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์', noModels: 'ยังไม่มีโมเดลที่กำหนดค่า', langbotModels: 'โมเดล LangBot', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 98ba9346e..0ed95e383 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -81,7 +81,7 @@ const viVN = { loading: 'Đang tải...', fieldRequired: 'Trường này là bắt buộc', or: 'hoặc', - loginWithSpace: 'Đăng nhập với Space', + loginWithSpace: 'Đăng nhập bằng tài khoản LangBot', spaceLoginRecommended: 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây', loginLocal: 'Đăng nhập với tài khoản cục bộ', @@ -281,7 +281,7 @@ const viVN = { searchProviders: 'Tìm kiếm nhà cung cấp...', langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space', credits: 'Tín dụng', - loginWithSpace: 'Đăng nhập với Space', + loginWithSpace: 'Đăng nhập bằng tài khoản LangBot', loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây', noModels: 'Chưa cấu hình mô hình nào', langbotModels: 'Mô hình LangBot', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a070e9436..8699cef30 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -79,7 +79,7 @@ const zhHans = { loading: '加载中...', fieldRequired: '此字段为必填项', or: '或', - loginWithSpace: '通过 Space 登录', + loginWithSpace: '使用 LangBot 账号登录', spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', loginLocal: '使用本地账号登录', loginWithPassword: '通过密码登录', @@ -265,7 +265,7 @@ const zhHans = { searchProviders: '搜索供应商...', langbotModelsDescription: 'LangBot Space 提供的云端模型', credits: '积分', - loginWithSpace: '通过 Space 登录', + loginWithSpace: '使用 LangBot 账号登录', loginToUseModels: '通过 Space 登录以使用云端模型', noModels: '暂无模型', langbotModels: 'LangBot 模型', @@ -1270,6 +1270,7 @@ const zhHans = { invitationEmailMismatch: '此邀请属于另一个邮箱地址。', existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。', acceptAsCurrentAccount: '使用当前账户接受', + switchAccount: '切换账号', registerAndAccept: '创建账户并接受', alreadyHaveAccount: '我已有账户', confirmPassword: '确认密码', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 39a114a95..b98817e74 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -79,7 +79,7 @@ const zhHant = { loading: '載入中...', fieldRequired: '此欄位為必填', or: '或', - loginWithSpace: '透過 Space 登入', + loginWithSpace: '使用 LangBot 帳號登入', spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', loginLocal: '使用本地帳號登入', loginWithPassword: '透過密碼登入', @@ -263,7 +263,7 @@ const zhHant = { searchProviders: '搜尋供應商...', langbotModelsDescription: '由 LangBot Space 提供的雲端模型', credits: '積分', - loginWithSpace: '使用 Space 登入', + loginWithSpace: '使用 LangBot 帳號登入', loginToUseModels: '使用 Space 登入以使用雲端模型', noModels: '暫無模型', langbotModels: 'LangBot 模型',