mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 20:30:59 +00:00
feat(workspace): add in-product collaboration and direct Cloud launch
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'この招待はすでに使用されています。',
|
||||
|
||||
@@ -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: '此邀请已被使用。',
|
||||
|
||||
@@ -62,10 +62,11 @@ test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it
|
||||
);
|
||||
});
|
||||
|
||||
test('links Cloud workspace managers to the Space member invitation surface', () => {
|
||||
assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
|
||||
assert.match(workspaceSettingsPanelSource, /#workspace-members/);
|
||||
test('keeps Cloud workspace member management in Workspace Settings', () => {
|
||||
assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /#workspace-members/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /cloudMembersURL/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /canManageCloudMembers/);
|
||||
});
|
||||
|
||||
test('keeps workspace plan and settings controls on the workspace row', () => {
|
||||
@@ -85,13 +86,7 @@ test('moves Cloud plan upgrades into Workspace Settings', () => {
|
||||
assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/);
|
||||
assert.match(workspaceSettingsPanelSource, /cloudPortalURL/);
|
||||
assert.match(workspaceSettingsPanelSource, /step=plan/);
|
||||
const toolbarEnd = workspaceSettingsPanelSource.indexOf('</PanelToolbar>');
|
||||
const membersHeading =
|
||||
workspaceSettingsPanelSource.indexOf('workspace.members');
|
||||
const cloudInvite = workspaceSettingsPanelSource.indexOf(
|
||||
'href={cloudMembersURL}',
|
||||
);
|
||||
assert.ok(toolbarEnd < membersHeading && membersHeading < cloudInvite);
|
||||
assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
|
||||
});
|
||||
|
||||
test('aligns the workspace trigger with navigation entries on both sides and truncates long names', () => {
|
||||
|
||||
Reference in New Issue
Block a user