feat(workspace): add in-product collaboration and direct Cloud launch

This commit is contained in:
dadachann
2026-07-26 10:40:36 +08:00
parent 276791e7af
commit 8a445bfb22
29 changed files with 1258 additions and 124 deletions
+41 -12
View File
@@ -27,6 +27,7 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = {
token: string;
user: string;
workspace_uuid?: string;
};
const pendingSpaceOAuthLogins = new Map<
@@ -37,15 +38,17 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
state: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<SpaceOAuthLoginResult> {
const requestKey = `${authCode}:${state}`;
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode, state)
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
.finally(() => {
pendingSpaceOAuthLogins.delete(requestKey);
});
@@ -70,21 +73,34 @@ function SpaceOAuthCallbackContent() {
const [localEmail, setLocalEmail] = useState<string>('');
const handleOAuthCallback = useCallback(
async (authCode: string, state: string) => {
async (
authCode: string,
state: string,
workspaceUuid?: string,
launchAssertion?: string,
) => {
try {
const response = await getOrCreateSpaceOAuthLoginPromise(
authCode,
state,
workspaceUuid,
launchAssertion,
);
if (!isMountedRef.current) {
return;
}
beginAuthenticatedSession(response.token, response.user);
const workspaceResult = await bootstrapWorkspaceSession();
const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: response.workspace_uuid,
});
if (workspaceResult.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
if (response.workspace_uuid) {
navigate('/home', { replace: true });
return;
}
setStatus('success');
toast.success(t('common.spaceLoginSuccess'));
@@ -171,6 +187,8 @@ function SpaceOAuthCallbackContent() {
const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode');
const state = searchParams.get('state');
const workspaceUuid = searchParams.get('workspace_uuid');
const launchAssertion = searchParams.get('launch_assertion');
if (error) {
setStatus('error');
@@ -180,15 +198,13 @@ function SpaceOAuthCallbackContent() {
return;
}
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
if (mode === 'bind') {
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
// Bind mode - verify state (token) exists
if (!state) {
setStatus('error');
@@ -199,7 +215,20 @@ function SpaceOAuthCallbackContent() {
setIsBindMode(true);
setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm');
} else if (workspaceUuid || launchAssertion) {
if (!workspaceUuid || !launchAssertion) {
setStatus('error');
setErrorMessage(t('common.spaceLoginFailed'));
return;
}
handleOAuthCallback(authCode ?? '', state ?? '', workspaceUuid, launchAssertion);
} else {
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
if (!state) {
setStatus('error');
setErrorMessage(t('common.spaceLoginFailed'));
@@ -76,20 +76,10 @@ export default function WorkspaceSettingsPanel({
const isCloudProjection =
workspaceInfo?.workspace.source === 'cloud_projection';
const canViewMembers = permissions.has('member.view');
const canInvite = !isCloudProjection && permissions.has('member.invite');
const canUpdateMembers =
!isCloudProjection && permissions.has('member.update_role');
const canRemoveMembers =
!isCloudProjection && permissions.has('member.remove');
const canTransferOwner =
!isCloudProjection && permissions.has('owner.transfer');
const canManageCloudMembers =
isCloudProjection &&
(workspaceInfo?.membership.role === 'owner' ||
workspaceInfo?.membership.role === 'admin');
const cloudMembersURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members`
: '';
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
@@ -104,7 +94,6 @@ export default function WorkspaceSettingsPanel({
current.permissions.includes('member.view')
? backendClient.getWorkspaceMembers(current.workspace.uuid)
: Promise.resolve({ members: [] }),
current.workspace.source === 'local' &&
current.permissions.includes('member.invite')
? backendClient.getWorkspaceInvitations(current.workspace.uuid)
: Promise.resolve({ invitations: [] }),
@@ -131,11 +120,10 @@ export default function WorkspaceSettingsPanel({
inviteEmail.trim(),
inviteRole,
);
const link = `${window.location.origin}/invitations/accept#token=${encodeURIComponent(response.token)}`;
setOneTimeInviteLink(link);
setOneTimeInviteLink(response.link);
setInviteEmail('');
await loadWorkspace();
toast.success(t('workspace.invitationCreated'));
toast.success(t(`workspace.delivery.${response.delivery.status}`));
} catch {
toast.error(t('workspace.invitationCreateFailed'));
} finally {
@@ -310,19 +298,6 @@ export default function WorkspaceSettingsPanel({
<h3 className="text-sm font-semibold">
{t('workspace.members')}
</h3>
{canManageCloudMembers && (
<Button asChild size="sm" variant="outline">
<a
href={cloudMembersURL}
target="_blank"
rel="noopener noreferrer"
>
<UserPlus className="size-4" />
{t('workspace.inviteMember')}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</div>
<div className="space-y-2">
{members.map((member) => {
+4
View File
@@ -352,6 +352,10 @@ export interface ApiRespSystemInfo {
enable_marketplace: boolean;
allow_modify_login_info: boolean;
disable_models_service: boolean;
invitation_delivery?: {
enabled: boolean;
provider: 'resend' | 'smtp' | null;
};
limitation: SystemLimitation;
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
* config.yaml). Shown on adapter config forms whose platform requires
+7
View File
@@ -51,3 +51,10 @@ export interface WorkspaceInvitation {
expires_at: string;
created_at: string;
}
export type WorkspaceInvitationDeliveryStatus = 'sent' | 'link_only' | 'failed';
export interface WorkspaceInvitationDelivery {
status: WorkspaceInvitationDeliveryStatus;
provider: 'resend' | 'smtp' | null;
}
+16 -2
View File
@@ -65,6 +65,7 @@ import type {
CurrentWorkspace,
Workspace,
WorkspaceInvitation,
WorkspaceInvitationDelivery,
WorkspaceMembership,
WorkspaceBootstrapResponse,
WorkspaceRole,
@@ -1192,7 +1193,12 @@ export class BackendClient extends BaseHttpClient {
workspaceUuid: string,
email: string,
role: Exclude<WorkspaceRole, 'owner'>,
): Promise<{ invitation: WorkspaceInvitation; token: string }> {
): Promise<{
invitation: WorkspaceInvitation;
token: string;
link: string;
delivery: WorkspaceInvitationDelivery;
}> {
return this.post(`/api/v1/workspaces/${workspaceUuid}/invitations`, {
email,
role,
@@ -1318,13 +1324,21 @@ export class BackendClient extends BaseHttpClient {
public async exchangeSpaceOAuthCode(
code: string,
state: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<{
token: string;
user: string;
workspace_uuid?: string;
}> {
const response = await this.instance.post(
'/api/v1/user/space/callback',
{ code, state },
{
code,
state,
workspace_uuid: workspaceUuid,
launch_assertion: launchAssertion,
},
{ skipWorkspace: true } as RequestConfig,
);
if (response.data.code !== 0) {
+4
View File
@@ -30,6 +30,10 @@ export const systemInfo: ApiRespSystemInfo = {
cloud_service_url: '',
allow_modify_login_info: true,
disable_models_service: false,
invitation_delivery: {
enabled: false,
provider: null,
},
limitation: {
max_bots: -1,
max_pipelines: -1,
+8 -4
View File
@@ -1300,7 +1300,7 @@ const enUS = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'Membership, invitations, and billing for this Workspace are managed in the LangBot Cloud portal.',
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
@@ -1310,9 +1310,13 @@ const enUS = {
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning:
'Copy this link now. The secret is shown only once and is not stored by LangBot.',
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
@@ -1323,7 +1327,7 @@ const enUS = {
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'The invitation secret is missing from this link.',
invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
+8 -3
View File
@@ -1301,7 +1301,7 @@ const jaJP = {
ossSingletonDescription:
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
cloudManagedDescription:
'このワークスペースのメンバー、招待、請求は LangBot Cloud ポータルで管理されます。',
'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。',
loadFailed: 'ワークスペース情報の読み込みに失敗しました',
members: 'メンバー',
you: 'あなた',
@@ -1311,9 +1311,14 @@ const jaJP = {
emailPlaceholder: 'member@example.com',
createInvitation: '招待を作成',
invitationCreated: '招待を作成しました',
delivery: {
sent: '招待メールを送信しました',
link_only: '招待リンクを作成しました',
failed: '招待リンクを作成しましたが、メールを送信できませんでした',
},
invitationCreateFailed: '招待の作成に失敗しました',
oneTimeLinkWarning:
'このリンクを今すぐコピーしてください。シークレットは一度だけ表示され、LangBotには保存されません。',
'このリンクを今すぐコピーしてください。一度だけ表示されます。',
copyInvitation: '招待リンクをコピー',
invitationCopied: '招待リンクをコピーしました',
pendingInvitations: '保留中の招待',
@@ -1324,7 +1329,7 @@ const jaJP = {
acceptInvitation: '招待を承認',
invitedToWorkspace: '{{workspace}} に招待されました',
checkingInvitation: '招待を確認しています...',
invitationMissing: '招待リンクにシークレットがありません。',
invitationMissing: 'この招待リンクには必要な情報がありません。',
invitationExpired: 'この招待は期限切れです。',
invitationAlreadyRevoked: 'この招待は取り消されました。',
invitationAlreadyUsed: 'この招待はすでに使用されています。',
+8 -4
View File
@@ -1234,7 +1234,7 @@ const zhHans = {
ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription:
'此工作区的成员、邀请与计费由 LangBot Cloud 控制台统一管理。',
'此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。',
loadFailed: '加载工作区信息失败',
members: '成员',
you: '你',
@@ -1243,9 +1243,13 @@ const zhHans = {
emailPlaceholder: 'member@example.com',
createInvitation: '创建邀请',
invitationCreated: '邀请已创建',
delivery: {
sent: '邀请邮件已发送',
link_only: '邀请链接已创建',
failed: '邀请链接已创建,但邮件发送失败',
},
invitationCreateFailed: '创建邀请失败',
oneTimeLinkWarning:
'请立即复制此链接。密钥只显示一次,LangBot 不会保存明文。',
oneTimeLinkWarning: '请立即复制此链接。它只显示一次。',
copyInvitation: '复制邀请链接',
invitationCopied: '邀请链接已复制',
pendingInvitations: '待接受邀请',
@@ -1256,7 +1260,7 @@ const zhHans = {
acceptInvitation: '接受邀请',
invitedToWorkspace: '你已受邀加入 {{workspace}}',
checkingInvitation: '正在验证邀请…',
invitationMissing: '邀请链接缺少密钥。',
invitationMissing: '邀请链接缺少必要信息。',
invitationExpired: '此邀请已过期。',
invitationAlreadyRevoked: '此邀请已被撤销。',
invitationAlreadyUsed: '此邀请已被使用。',