feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 9eb292992d
commit c6f826fe2d
271 changed files with 31162 additions and 6106 deletions
+39 -16
View File
@@ -1,6 +1,10 @@
import { useEffect, useState, useCallback, Suspense, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
beginAuthenticatedSession,
bootstrapWorkspaceSession,
} from '@/app/infra/http';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
@@ -32,19 +36,21 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
state: string,
): Promise<SpaceOAuthLoginResult> {
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
const requestKey = `${authCode}:${state}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode)
.exchangeSpaceOAuthCode(authCode, state)
.finally(() => {
pendingSpaceOAuthLogins.delete(authCode);
pendingSpaceOAuthLogins.delete(requestKey);
});
pendingSpaceOAuthLogins.set(authCode, requestPromise);
pendingSpaceOAuthLogins.set(requestKey, requestPromise);
return requestPromise;
}
@@ -64,23 +70,31 @@ function SpaceOAuthCallbackContent() {
const [localEmail, setLocalEmail] = useState<string>('');
const handleOAuthCallback = useCallback(
async (authCode: string) => {
async (authCode: string, state: string) => {
try {
const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
const response = await getOrCreateSpaceOAuthLoginPromise(
authCode,
state,
);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
beginAuthenticatedSession(response.token, response.user);
const workspaceResult = await bootstrapWorkspaceSession();
if (workspaceResult.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
setStatus('success');
toast.success(t('common.spaceLoginSuccess'));
// If wizard state exists, redirect back to wizard instead of home
const wizardState = localStorage.getItem('langbot_wizard_state');
const redirectTo = wizardState ? '/wizard' : '/home';
const destination = wizardState ? '/wizard' : '/home';
const redirectTo =
workspaceResult.status === 'selection-required'
? `/workspaces/select?returnTo=${encodeURIComponent(destination)}`
: destination;
setTimeout(() => {
navigate(redirectTo);
}, 1000);
@@ -113,14 +127,19 @@ function SpaceOAuthCallbackContent() {
return;
}
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
beginAuthenticatedSession(response.token, response.user);
const workspaceResult = await bootstrapWorkspaceSession();
if (workspaceResult.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
setStatus('success');
toast.success(t('account.bindSpaceSuccess'));
const redirectTo =
workspaceResult.status === 'selection-required'
? '/workspaces/select?returnTo=%2Fhome'
: '/home';
setTimeout(() => {
navigate('/home');
navigate(redirectTo);
}, 1000);
} catch (err) {
if (!isMountedRef.current) {
@@ -181,8 +200,12 @@ function SpaceOAuthCallbackContent() {
setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm');
} else {
// Normal login/register mode
handleOAuthCallback(authCode);
if (!state) {
setStatus('error');
setErrorMessage(t('common.spaceLoginFailed'));
return;
}
handleOAuthCallback(authCode, state);
}
return () => {
isMountedRef.current = false;
+94 -69
View File
@@ -29,11 +29,17 @@ import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canViewAudit =
currentWorkspace?.permissions.includes('audit.view') ?? false;
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
@@ -131,19 +137,23 @@ export default function BotDetailContent({ id }: { id: string }) {
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
{canManage && (
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
/>
<fieldset className="contents" disabled={!canManage}>
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
/>
</fieldset>
</div>
</div>
</div>
@@ -164,6 +174,7 @@ export default function BotDetailContent({ id }: { id: string }) {
id="bot-enable-switch"
checked={botEnabled}
onCheckedChange={handleEnableToggle}
disabled={!canManage}
/>
<Label
htmlFor="bot-enable-switch"
@@ -174,14 +185,16 @@ export default function BotDetailContent({ id }: { id: string }) {
</div>
)}
</div>
<Button
type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
@@ -197,14 +210,18 @@ export default function BotDetailContent({ id }: { id: string }) {
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
{canViewAudit && (
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
)}
{canViewAudit && (
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
)}
</TabsList>
{activeTab === 'sessions' && (
<button
@@ -239,60 +256,68 @@ export default function BotDetailContent({ id }: { id: string }) {
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<BotForm
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
/>
<fieldset className="contents" disabled={!canManage}>
<BotForm
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
/>
</fieldset>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('bots.dangerZone')}
</CardTitle>
<CardDescription>
{t('bots.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
{canManage && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('bots.dangerZone')}
</CardTitle>
<CardDescription>
{t('bots.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* Tab: Logs */}
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
{canViewAudit && (
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
)}
{/* Tab: Sessions */}
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
{canViewAudit && (
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
)}
</Tabs>
</div>
@@ -120,6 +120,7 @@ export default function BotForm({
// Track whether initial data loading is complete.
// setValue calls during init should NOT mark the form as dirty.
const isInitializing = useRef(true);
const initializationSequence = useRef(0);
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
useState(new Map<string, IDynamicFormItemSchema[]>());
@@ -141,7 +142,7 @@ export default function BotForm({
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
IDynamicFormItemSchema[]
>([]);
const [, setIsLoading] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
@@ -162,50 +163,55 @@ export default function BotForm({
}, [isDirty, onDirtyChange]);
useEffect(() => {
setBotFormValues();
}, []);
void setBotFormValues();
return () => {
// React StrictMode may start the effect twice. Invalidate the older
// request so it cannot reset fields after the user starts editing.
initializationSequence.current += 1;
};
}, [initBotId]);
function setBotFormValues() {
async function setBotFormValues() {
const sequence = ++initializationSequence.current;
isInitializing.current = true;
initBotFormComponent().then(() => {
if (initBotId) {
getBotConfig(initBotId)
.then((val) => {
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
});
handleAdapterSelect(val.adapter);
setIsLoading(true);
try {
await initBotFormComponent();
if (sequence !== initializationSequence.current) return;
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
setExtraWebhookUrl(val.extra_webhook_full_url || '');
})
.catch((err) => {
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
if (initBotId) {
const val = await getBotConfig(initBotId);
if (sequence !== initializationSequence.current) return;
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
});
handleAdapterSelect(val.adapter);
setWebhookUrl(val.webhook_full_url || '');
setExtraWebhookUrl(val.extra_webhook_full_url || '');
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
});
} catch (err) {
if (sequence === initializationSequence.current) {
toast.error(t('bots.getBotConfigError') + (err as CustomApiError).msg);
}
} finally {
if (sequence === initializationSequence.current) {
isInitializing.current = false;
setIsLoading(false);
}
}
}
async function initBotFormComponent() {
@@ -373,10 +379,6 @@ export default function BotForm({
.createBot(newBot)
.then((res) => {
toast.success(t('bots.createSuccess'));
initBotId = res.uuid;
setBotFormValues();
onNewBotCreated(res.uuid);
})
.catch((err) => {
@@ -395,243 +397,256 @@ export default function BotForm({
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
aria-busy={isLoading}
>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.botName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Pipeline Binding (edit mode only) */}
{initBotId && (
<fieldset className="contents" disabled={isLoading}>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('bots.routingConnection')}</CardTitle>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>
{t('bots.routingConnectionDescription')}
{t('bots.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="use_pipeline_uuid"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormLabel>
{t('bots.botName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
{field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>{pipeline?.label ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{/* Card 3: Adapter Configuration */}
<Card>
<CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription>
{t('bots.adapterConfigDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(field.value)}
alt=""
className="h-5 w-5 rounded"
{/* Card 2: Pipeline Binding (edit mode only) */}
{initBotId && (
<Card>
<CardHeader>
<CardTitle>{t('bots.routingConnection')}</CardTitle>
<CardDescription>
{t('bots.routingConnectionDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
{field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>
{pipeline?.label ?? field.value}
</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
<span>
{adapterNameList.find(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue
placeholder={t('bots.selectAdapter')}
/>
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{currentAdapter &&
(() => {
const docUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
>
{t('bots.viewAdapterDocs')}
<ExternalLink className="h-3 w-3" />
</a>
) : null;
})()}
</div>
</FormControl>
{currentAdapter && adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
<FormMessage />
</FormItem>
)}
/>
/>
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
/>
</CardContent>
</Card>
)}
{/* Card 3: Adapter Configuration */}
<Card>
<CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription>
{t('bots.adapterConfigDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
field.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
{adapterNameList.find(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue
placeholder={t('bots.selectAdapter')}
/>
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{currentAdapter &&
(() => {
const docUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
>
{t('bots.viewAdapterDocs')}
<ExternalLink className="h-3 w-3" />
</a>
) : null;
})()}
</div>
</FormControl>
{currentAdapter &&
adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
/>
)}
</CardContent>
</Card>
</fieldset>
</form>
</Form>
);
@@ -58,19 +58,9 @@ export default function AccountSettingsPanel({
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -40,12 +40,20 @@ import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey {
id: number;
uuid: string;
name: string;
key: string;
description: string;
scopes: string[];
status: 'active' | 'revoked';
secret_available: false;
created_at: string;
}
type CreatedApiKey = Omit<ApiKey, 'secret_available'> & {
key: string;
secret_available: true;
};
interface Webhook {
id: number;
name: string;
@@ -71,7 +79,7 @@ export default function ApiIntegrationPanel({
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
// Webhook state
@@ -135,7 +143,7 @@ export default function ApiIntegrationPanel({
const response = (await backendClient.post('/api/v1/apikeys', {
name: newKeyName,
description: newKeyDescription,
})) as { key: ApiKey };
})) as { key: CreatedApiKey };
setCreatedKey(response.key);
toast.success(t('common.apiKeyCreated'));
@@ -184,11 +192,6 @@ export default function ApiIntegrationPanel({
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
};
const maskApiKey = (key: string) => {
if (key.length <= 8) return key;
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
};
// Webhook methods
const loadWebhooks = async () => {
setLoading(true);
@@ -337,25 +340,12 @@ export default function ApiIntegrationPanel({
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
<span className="text-sm text-muted-foreground">
{t('common.apiKeyStoredSecurely')}
</span>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
@@ -188,15 +188,10 @@ export default function DynamicFormItemComponent({
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.getSpaceBindAuthorizeUrl(redirectUri)
.then((response) => {
window.location.href = response.authorize_url;
})
@@ -4,7 +4,11 @@ import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
import {
clearUserInfo,
getCloudServiceClientSync,
useCurrentWorkspace,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
Moon,
@@ -380,6 +384,11 @@ function NavItems({
const sidebarData = useSidebarData();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManageResources =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperateRuntime =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
// Track which entity categories have their full list expanded
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
loadListExpansionState,
@@ -513,6 +522,9 @@ function NavItems({
<>
{sectionItems.map((config) => {
if (!isEntityCategory(config.id)) {
if (config.id === 'add-extension' && !canManageResources) {
return null;
}
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
return (
<SidebarMenuItem key={config.id}>
@@ -552,7 +564,8 @@ function NavItems({
: sidebarData[entityKey];
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
const canCreate =
canManageResources && CREATABLE_CATEGORIES.includes(categoryId);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
const isPlugin = categoryId === 'plugins';
const isSkill = categoryId === 'skills';
@@ -800,6 +813,7 @@ function NavItems({
{itemIsPluginType && !item.debug && (
<PluginItemMenu
item={item}
canManage={canManageResources}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
@@ -1063,7 +1077,7 @@ function NavItems({
{config.name}
</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{isExtensionsCategory && (
{isExtensionsCategory && canOperateRuntime && (
<button
type="button"
title={t('common.refresh', '刷新')}
@@ -1330,10 +1344,12 @@ function NavItems({
// Dropdown menu for plugin sidebar sub-items (shown on hover)
function PluginItemMenu({
item,
canManage,
onUpdate,
onDelete,
}: {
item: SidebarEntityItem;
canManage: boolean;
onUpdate: () => void;
onDelete: () => void;
}) {
@@ -1344,6 +1360,8 @@ function PluginItemMenu({
const isGithub = item.installSource === 'github';
const hasSourceLink = isMarketplace || isGithub;
if (!canManage && !hasSourceLink) return null;
function handleViewSource() {
const slashIdx = item.id.indexOf('/');
const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : '';
@@ -1384,7 +1402,7 @@ function PluginItemMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
{canManage && isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -1413,16 +1431,18 @@ function PluginItemMenu({
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
{canManage && (
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -1612,6 +1632,7 @@ export default function HomeSidebar({
useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
@@ -1678,10 +1699,6 @@ export default function HomeSidebar({
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
localStorage.setItem('token', 'test-token');
localStorage.setItem('userEmail', 'test@example.com');
}
const storedEmail = localStorage.getItem('userEmail');
if (storedEmail) {
@@ -1820,6 +1837,7 @@ export default function HomeSidebar({
}
function handleLogout() {
clearUserInfo();
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
window.location.href = '/login';
@@ -1948,18 +1966,20 @@ export default function HomeSidebar({
</SidebarMenuItem>
</SidebarMenu>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* API-key management is available only to authorized Workspace roles. */}
{currentWorkspace?.permissions.includes('api_key.manage') && (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)}
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>
@@ -24,6 +24,7 @@ import {
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -83,6 +84,9 @@ export default function ModelsPanel({
onBlockingChange,
}: ModelsPanelProps) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
@@ -270,17 +274,9 @@ export default function ModelsPanel({
async function handleSpaceLogin() {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -544,6 +540,7 @@ export default function ModelsPanel({
<ProviderCard
key={provider.uuid}
provider={provider}
canManage={canManage}
isLangBotModels={isLangBotModels}
supportTypes={requesterSupportTypes[provider.requester]}
isExpanded={expandedProviders.has(provider.uuid)}
@@ -628,10 +625,12 @@ export default function ModelsPanel({
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
{canManage && (
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
)}
</div>
{/* Provider List */}
@@ -18,6 +18,7 @@ import { userInfo } from '@/app/infra/http';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
editModelPopoverOpen: string | null;
@@ -71,6 +72,7 @@ function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
export default function ModelItem({
model,
canManage,
modelType,
isLangBotModels,
editModelPopoverOpen,
@@ -149,7 +151,7 @@ export default function ModelItem({
// Check if popover should be disabled (space models when not logged in)
const isPopoverDisabled =
isLangBotModels && userInfo?.account_type !== 'space';
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
return (
<Popover
@@ -193,7 +195,7 @@ export default function ModelItem({
</Badge>
)}
</div>
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
onOpenChange={(open) =>
@@ -38,6 +38,7 @@ import AddModelPopover from './AddModelPopover';
interface ProviderCardProps {
provider: ModelProvider;
canManage: boolean;
isLangBotModels?: boolean;
supportTypes?: string[];
isExpanded: boolean;
@@ -101,6 +102,7 @@ function maskApiKey(key: string): string {
export default function ProviderCard({
provider,
canManage,
isLangBotModels = false,
supportTypes,
isExpanded,
@@ -196,7 +198,7 @@ export default function ProviderCard({
</div>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{isLangBotModels && accountType !== 'space' && (
{canManage && isLangBotModels && accountType !== 'space' && (
<Button
variant="outline"
size="sm"
@@ -232,7 +234,7 @@ export default function ProviderCard({
</Button>
</div>
)}
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<>
<Button
variant="ghost"
@@ -317,7 +319,7 @@ export default function ProviderCard({
) : (
<div />
)}
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<div className="flex items-center gap-1">
<AddModelPopover
isOpen={
@@ -404,6 +406,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -441,6 +444,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -472,6 +476,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -1,6 +1,12 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
import {
HardDrive,
KeyRound,
Settings,
Sparkles,
UsersRound,
} from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -22,10 +28,13 @@ import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
import { useCurrentWorkspace } from '@/app/infra/http';
// The set of settings sections shown in the unified dialog. The string values
// are also reused as the ?action= query param suffix so deep links keep working.
export type SettingsSection =
| 'workspace'
| 'account'
| 'apiIntegration'
| 'models'
@@ -35,6 +44,7 @@ export type SettingsSection =
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
workspace: 'showWorkspaceSettings',
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
@@ -63,6 +73,7 @@ export default function SettingsDialog({
onSectionChange,
}: SettingsDialogProps) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
// A nested modal (e.g. the provider form) can request that we ignore
// outer-close until it is dismissed.
const [blocking, setBlocking] = useState(false);
@@ -76,13 +87,20 @@ export default function SettingsDialog({
}
}, [section, open]);
const navItems: {
const allNavItems: {
id: SettingsSection;
label: string;
title: string;
description: string;
icon: React.ReactNode;
}[] = [
{
id: 'workspace',
label: t('settingsDialog.nav.workspace'),
title: t('workspace.title'),
description: t('workspace.description'),
icon: <UsersRound className="size-4" />,
},
{
id: 'models',
label: t('settingsDialog.nav.models'),
@@ -112,6 +130,27 @@ export default function SettingsDialog({
icon: <Settings className="size-4" />,
},
];
const permissions = currentWorkspace?.permissions ?? [];
const canManageApiKeys = permissions.includes('api_key.manage');
const canViewAudit = permissions.includes('audit.view');
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
}
if (item.id === 'storageAnalysis') {
return canViewAudit;
}
return true;
});
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewAudit);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
@@ -201,6 +240,11 @@ export default function SettingsDialog({
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{section === 'workspace' && (
<WorkspaceSettingsPanel
active={open && section === 'workspace'}
/>
)}
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
@@ -0,0 +1,398 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Copy, Loader2, Trash2, UserPlus, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@/components/ui/item';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
CurrentWorkspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceRole,
} from '@/app/infra/entities/workspace';
import { backendClient } from '@/app/infra/http';
import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
import WorkspaceSwitcher from './WorkspaceSwitcher';
interface WorkspaceSettingsPanelProps {
active: boolean;
}
const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
'admin',
'developer',
'operator',
'viewer',
];
export default function WorkspaceSettingsPanel({
active,
}: WorkspaceSettingsPanelProps) {
const { t } = useTranslation();
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
null,
);
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
const [loading, setLoading] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] =
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
const [inviteLoading, setInviteLoading] = useState(false);
const [oneTimeInviteLink, setOneTimeInviteLink] = useState<string | null>(
null,
);
const permissions = useMemo(
() => new Set(workspaceInfo?.permissions ?? []),
[workspaceInfo],
);
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 loadWorkspace = useCallback(async () => {
setLoading(true);
try {
const current = await backendClient.getCurrentWorkspace();
setWorkspaceInfo(current);
const [memberResponse, invitationResponse] = await Promise.all([
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: [] }),
]);
setMembers(memberResponse.members);
setInvitations(invitationResponse.invitations);
} catch {
toast.error(t('workspace.loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
if (active) void loadWorkspace();
}, [active, loadWorkspace]);
async function createInvitation() {
if (!workspaceInfo || !inviteEmail.trim()) return;
setInviteLoading(true);
try {
const response = await backendClient.createWorkspaceInvitation(
workspaceInfo.workspace.uuid,
inviteEmail.trim(),
inviteRole,
);
const link = `${window.location.origin}/invitations/accept#token=${encodeURIComponent(response.token)}`;
setOneTimeInviteLink(link);
setInviteEmail('');
await loadWorkspace();
toast.success(t('workspace.invitationCreated'));
} catch {
toast.error(t('workspace.invitationCreateFailed'));
} finally {
setInviteLoading(false);
}
}
async function copyInvitationLink() {
if (!oneTimeInviteLink) return;
await navigator.clipboard.writeText(oneTimeInviteLink);
toast.success(t('workspace.invitationCopied'));
}
async function updateMemberRole(
member: WorkspaceMembership,
role: WorkspaceRole,
) {
if (!workspaceInfo || member.role === role) return;
try {
await backendClient.updateWorkspaceMemberRole(
workspaceInfo.workspace.uuid,
member.account_uuid,
role,
);
await loadWorkspace();
toast.success(t('workspace.memberUpdated'));
} catch {
toast.error(t('workspace.memberUpdateFailed'));
}
}
async function removeMember(member: WorkspaceMembership) {
if (!workspaceInfo) return;
if (!window.confirm(t('workspace.removeMemberConfirm'))) return;
try {
await backendClient.removeWorkspaceMember(
workspaceInfo.workspace.uuid,
member.account_uuid,
);
await loadWorkspace();
toast.success(t('workspace.memberRemoved'));
} catch {
toast.error(t('workspace.memberRemoveFailed'));
}
}
async function revokeInvitation(invitation: WorkspaceInvitation) {
if (!workspaceInfo) return;
try {
await backendClient.revokeWorkspaceInvitation(
workspaceInfo.workspace.uuid,
invitation.uuid,
);
await loadWorkspace();
toast.success(t('workspace.invitationRevoked'));
} catch {
toast.error(t('workspace.invitationRevokeFailed'));
}
}
if (loading && !workspaceInfo) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<>
<PanelToolbar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{workspaceInfo?.workspace.name ?? t('workspace.title')}
</p>
<p className="text-xs text-muted-foreground">
{t(
isCloudProjection
? 'workspace.cloudManagedDescription'
: 'workspace.ossSingletonDescription',
)}
</p>
</div>
<WorkspaceSwitcher className="ml-auto" />
{workspaceInfo && (
<Badge variant="secondary">
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge>
)}
</PanelToolbar>
<PanelBody className="space-y-6">
{canInvite && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">
{t('workspace.inviteMember')}
</h3>
<p className="text-xs text-muted-foreground">
{t('workspace.inviteDescription')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder={t('workspace.emailPlaceholder')}
className="flex-1"
/>
<Select
value={inviteRole}
onValueChange={(value) =>
setInviteRole(value as Exclude<WorkspaceRole, 'owner'>)
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={createInvitation}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
{t('workspace.createInvitation')}
</Button>
</div>
{oneTimeInviteLink && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="mb-2 text-xs text-muted-foreground">
{t('workspace.oneTimeLinkWarning')}
</p>
<div className="flex gap-2">
<Input value={oneTimeInviteLink} readOnly />
<Button
variant="outline"
size="icon"
onClick={copyInvitationLink}
aria-label={t('workspace.copyInvitation')}
>
<Copy className="size-4" />
</Button>
</div>
</div>
)}
</section>
)}
{canViewMembers && (
<section className="space-y-3">
<h3 className="text-sm font-semibold">{t('workspace.members')}</h3>
<div className="space-y-2">
{members.map((member) => {
const isSelf =
member.account_uuid ===
workspaceInfo?.membership.account_uuid;
return (
<Item
key={member.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Users className="size-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>
{member.email}
{isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge>
)}
</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${member.role}`)}
</ItemDescription>
</ItemContent>
<ItemActions>
{canUpdateMembers && member.role !== 'owner' && (
<Select
value={member.role}
onValueChange={(role) =>
void updateMemberRole(member, role as WorkspaceRole)
}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
{canTransferOwner && (
<SelectItem value="owner">
{t('workspace.transferOwnership')}
</SelectItem>
)}
</SelectContent>
</Select>
)}
{canRemoveMembers &&
!isSelf &&
member.role !== 'owner' && (
<Button
variant="ghost"
size="icon"
onClick={() => void removeMember(member)}
aria-label={t('workspace.removeMember')}
>
<Trash2 className="size-4" />
</Button>
)}
</ItemActions>
</Item>
);
})}
</div>
</section>
)}
{canInvite && invitations.length > 0 && (
<section className="space-y-3">
<h3 className="text-sm font-semibold">
{t('workspace.pendingInvitations')}
</h3>
<div className="space-y-2">
{invitations.map((invitation) => (
<Item
key={invitation.uuid}
size="sm"
variant="outline"
className="rounded-lg"
>
<ItemContent>
<ItemTitle>{invitation.normalized_email}</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${invitation.role}`)} ·{' '}
{t('workspace.expiresAt', {
date: new Date(invitation.expires_at).toLocaleString(),
})}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
onClick={() => void revokeInvitation(invitation)}
aria-label={t('workspace.revokeInvitation')}
>
<Trash2 className="size-4" />
</Button>
</ItemActions>
</Item>
))}
</div>
</section>
)}
</PanelBody>
</>
);
}
@@ -0,0 +1,56 @@
import { Building2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
switchWorkspaceAndReload,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
export default function WorkspaceSwitcher({
className,
}: {
className?: string;
}) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
if (!currentWorkspace || workspaces.length <= 1) return null;
return (
<Select
value={currentWorkspace.workspace.uuid}
onValueChange={switchWorkspaceAndReload}
>
<SelectTrigger
size="sm"
className={cn('min-w-44 max-w-64', className)}
aria-label={t('workspace.switchWorkspace')}
>
<Building2 className="size-4 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{workspaces.map((entry) => (
<SelectItem key={entry.workspace.uuid} value={entry.workspace.uuid}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{entry.workspace.name}</span>
<span className="text-xs text-muted-foreground">
{t(`workspace.roles.${entry.membership.role}`)}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+73 -57
View File
@@ -27,11 +27,15 @@ import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
useSidebarData();
@@ -119,18 +123,22 @@ export default function KBDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('knowledge.createKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
{canManage && (
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<KBForm
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
<fieldset className="contents" disabled={!canManage}>
<KBForm
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
</fieldset>
</div>
</div>
</div>
@@ -146,14 +154,16 @@ export default function KBDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')}
</h1>
<Button
type="submit"
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
@@ -186,45 +196,49 @@ export default function KBDetailContent({ id }: { id: string }) {
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<KBForm
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/>
<fieldset className="contents" disabled={!canManage}>
<KBForm
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/>
</fieldset>
{/* Danger Zone Card */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('knowledge.dangerZone')}
</CardTitle>
<CardDescription>
{t('knowledge.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('knowledge.deleteKbAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('knowledge.deleteKbHint')}
</p>
{canManage && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('knowledge.dangerZone')}
</CardTitle>
<CardDescription>
{t('knowledge.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('knowledge.deleteKbAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('knowledge.deleteKbHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
</div>
</TabsContent>
@@ -234,11 +248,13 @@ export default function KBDetailContent({ id }: { id: string }) {
value="documents"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<KBDoc
kbId={id}
ragEngineName={kbInfo?.knowledge_engine?.name}
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
/>
<fieldset className="contents" disabled={!canManage}>
<KBDoc
kbId={id}
ragEngineName={kbInfo?.knowledge_engine?.name}
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
/>
</fieldset>
</TabsContent>
)}
+68 -35
View File
@@ -14,10 +14,10 @@ import {
} from '@/app/home/components/home-sidebar/SidebarDataContext';
import { I18nObject } from '@/app/infra/entities/common';
import {
userInfo,
bootstrapWorkspaceSession,
systemInfo,
initializeUserInfo,
initializeSystemInfo,
useCurrentWorkspace,
} from '@/app/infra/http';
import { useNavigate, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
@@ -43,6 +43,7 @@ import {
PluginInstallProgressDialog,
} from '@/app/home/plugins/components/plugin-install-task';
import { setDocumentTitle } from '@/hooks/useDocumentTitle';
import WorkspaceSwitcher from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [
@@ -82,8 +83,6 @@ function isExtensionsRoute(pathname: string): boolean {
}
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
'langbot_backend_unavailable_return_to';
export default function HomeLayout({
children,
@@ -92,45 +91,78 @@ export default function HomeLayout({
}>) {
const navigate = useNavigate();
const location = useLocation();
const currentWorkspace = useCurrentWorkspace();
const [identityReady, setIdentityReady] = useState(false);
// Initialize user info if not already initialized
useEffect(() => {
if (!userInfo) {
initializeUserInfo();
}
}, []);
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
// Resolve the instance, Account, and Workspace before mounting any
// Workspace-owned page. The second system-info read uses the now-stable
// selector and therefore returns the selected Workspace's wizard metadata.
useEffect(() => {
let cancelled = false;
const checkWizard = async () => {
try {
// Always re-fetch to ensure we have the latest wizard_status from backend
await initializeSystemInfo({ throwOnError: true });
if (!cancelled && systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
} catch {
if (!cancelled) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
sessionStorage.setItem(
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
returnTo,
bootstrapWorkspaceSession()
.then(async (result) => {
if (result.status === 'selection-required') {
const returnTo = `${location.pathname}${location.search}`;
navigate(
`/workspaces/select?returnTo=${encodeURIComponent(returnTo)}`,
{ replace: true },
);
navigate('/backend-unavailable', {
replace: true,
state: { from: returnTo },
});
return false;
}
}
};
checkWizard();
if (result.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
await initializeSystemInfo({ throwOnError: true });
return true;
})
.then((ready) => {
if (!cancelled && ready) setIdentityReady(true);
})
.catch(() => {
if (!cancelled) navigate('/login', { replace: true });
});
return () => {
cancelled = true;
};
}, [location.hash, location.pathname, location.search, navigate]);
}, [location.pathname, location.search, navigate]);
// A read-only member may view resources but must not enter mutation-only
// routes. The backend remains the authoritative authorization boundary.
useEffect(() => {
if (!identityReady || !currentWorkspace) return;
if (currentWorkspace.permissions.includes('resource.manage')) return;
const params = new URLSearchParams(location.search);
const createOnlyRoute =
location.pathname === '/home/add-extension' ||
params.get('id') === 'new' ||
(location.pathname === '/home/skills' &&
params.get('action') === 'create');
if (createOnlyRoute) {
const fallback =
location.pathname === '/home/add-extension'
? '/home/extensions'
: location.pathname;
navigate(fallback, { replace: true });
}
}, [
currentWorkspace,
identityReady,
location.pathname,
location.search,
navigate,
]);
// Auto-redirect only after the Workspace bootstrap above has loaded the
// selected Workspace's wizard state.
useEffect(() => {
if (!identityReady) return;
if (systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
}, [identityReady, navigate]);
if (!identityReady) return <div />;
return (
<SidebarDataProvider>
@@ -233,6 +265,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
)}
</BreadcrumbList>
</Breadcrumb>
<WorkspaceSwitcher className="ml-auto w-44" />
</div>
</header>
+74 -55
View File
@@ -26,6 +26,7 @@ import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataCo
import { useTranslation } from 'react-i18next';
import { Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
type MCPConnectionState =
@@ -39,6 +40,11 @@ export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperate =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
const server = mcpServers.find((s) => s.id === id);
@@ -212,21 +218,25 @@ export default function MCPDetailContent({ id }: { id: string }) {
</Badge>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate('/home/add-extension')}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
{canOperate && (
<Button
type="button"
variant="outline"
onClick={() => navigate('/home/add-extension')}
>
{t('common.cancel')}
</Button>
)}
{canManage && (
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
)}
<Button
type="submit"
form="mcp-form"
@@ -243,15 +253,17 @@ export default function MCPDetailContent({ id }: { id: string }) {
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={undefined}
layout="split"
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
/>
<fieldset className="contents" disabled={!canManage}>
<MCPForm
ref={formRef}
initServerName={undefined}
layout="split"
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
/>
</fieldset>
</div>
</div>
);
@@ -274,6 +286,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
disabled={!canManage}
/>
</div>
</CardContent>
@@ -335,41 +348,47 @@ export default function MCPDetailContent({ id }: { id: string }) {
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button
type="submit"
form="mcp-form"
disabled={!formDirty || saveBlockedByBox}
>
{t('common.save')}
</Button>
{canOperate && (
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
)}
{canManage && (
<Button
type="submit"
form="mcp-form"
disabled={!formDirty || saveBlockedByBox}
>
{t('common.save')}
</Button>
)}
</div>
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={id}
layout="split"
sideHeader={enableControl}
sideFooter={editActions}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
onPersistedTestComplete={handlePersistedTestComplete}
/>
<fieldset className="contents" disabled={!canManage}>
<MCPForm
ref={formRef}
initServerName={id}
layout="split"
sideHeader={enableControl}
sideFooter={canManage ? editActions : undefined}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
onPersistedTestComplete={handlePersistedTestComplete}
/>
</fieldset>
</div>
</div>
@@ -8,11 +8,19 @@ import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-ta
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, Bug, BarChart3 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function PipelineDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperate =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
const canViewAudit =
currentWorkspace?.permissions.includes('audit.view') ?? false;
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
@@ -47,22 +55,26 @@ export default function PipelineDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
{canManage && (
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6">
<PipelineFormComponent
pipelineId={undefined}
isEditMode={false}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
/>
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
pipelineId={undefined}
isEditMode={false}
disableForm={!canManage}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
/>
</fieldset>
</div>
</div>
</div>
@@ -80,14 +92,16 @@ export default function PipelineDetailContent({ id }: { id: string }) {
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
@@ -102,21 +116,25 @@ export default function PipelineDetailContent({ id }: { id: string }) {
<Settings className="size-3.5" />
{t('pipelines.configuration')}
</TabsTrigger>
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
{canOperate && (
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
)}
{canViewAudit && (
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
)}
</TabsList>
{/* Tab: Configuration */}
@@ -124,41 +142,47 @@ export default function PipelineDetailContent({ id }: { id: string }) {
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
</fieldset>
</TabsContent>
{/* Tab: Debug */}
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
{canOperate && (
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
)}
{/* Tab: Monitoring */}
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
}}
/>
</TabsContent>
{canViewAudit && (
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
}}
/>
</TabsContent>
)}
</Tabs>
</div>
);
+49 -33
View File
@@ -24,11 +24,15 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Sparkles, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function SkillDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
@@ -88,14 +92,16 @@ export default function SkillDetailContent({ id }: { id: string }) {
</Badge>
</div>
</div>
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
)}
</div>
{!boxAvailable && (
@@ -105,13 +111,17 @@ export default function SkillDetailContent({ id }: { id: string }) {
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={() => {}}
/>
<fieldset className="contents" disabled={!canManage}>
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) =>
handleImportedSkills([skillName])
}
onSkillUpdated={() => {}}
/>
</fieldset>
</div>
</div>
);
@@ -133,16 +143,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
{t('skills.deleteConfirmation')}
</p>
</div>
<Button
variant="destructive"
type="button"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
{canManage && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
)}
</div>
</CardContent>
</Card>
@@ -185,14 +197,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
)}
<div className="min-h-0 flex-1">
<SkillForm
key={id}
initSkillName={id}
layout="split"
sideFooter={editActions}
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={handleSkillUpdated}
/>
<fieldset className="contents" disabled={!canManage}>
<SkillForm
key={id}
initSkillName={id}
layout="split"
sideFooter={canManage ? editActions : undefined}
onNewSkillCreated={(skillName) =>
handleImportedSkills([skillName])
}
onSkillUpdated={handleSkillUpdated}
/>
</fieldset>
</div>
</div>
+18 -8
View File
@@ -7,9 +7,13 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function SkillsPage() {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
@@ -61,7 +65,11 @@ export default function SkillsPage() {
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
<Button
type="submit"
form="skill-form"
disabled={!boxAvailable || !canManage}
>
{t('common.save')}
</Button>
</div>
@@ -72,13 +80,15 @@ export default function SkillsPage() {
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
<fieldset className="contents" disabled={!canManage}>
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
</fieldset>
</div>
</div>
);
+51
View File
@@ -0,0 +1,51 @@
export type WorkspaceRole =
| 'owner'
| 'admin'
| 'developer'
| 'operator'
| 'viewer';
export interface Workspace {
uuid: string;
instance_uuid: string;
name: string;
slug: string;
type: 'personal' | 'team';
status: 'provisioning' | 'active' | 'suspended' | 'archived' | 'deleted';
source: 'local' | 'cloud_projection';
}
export interface WorkspaceMembership {
uuid: string;
workspace_uuid: string;
account_uuid: string;
email: string;
role: WorkspaceRole;
status: 'active' | 'disabled' | 'removed';
joined_at: string | null;
created_at: string;
}
export interface CurrentWorkspace {
workspace: Workspace;
membership: WorkspaceMembership;
permissions: string[];
placement_generation: number;
}
/** Account-scoped Workspace entry returned before a Workspace is selected. */
export type WorkspaceBootstrapEntry = CurrentWorkspace;
export interface WorkspaceBootstrapResponse {
workspaces: WorkspaceBootstrapEntry[];
}
export interface WorkspaceInvitation {
uuid: string;
workspace_uuid: string;
normalized_email: string;
role: Exclude<WorkspaceRole, 'owner'>;
status: 'pending' | 'accepted' | 'revoked' | 'expired';
expires_at: string;
created_at: string;
}
+185 -40
View File
@@ -1,4 +1,4 @@
import { BaseHttpClient } from './BaseHttpClient';
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import {
ApiRespProviderRequesters,
ApiRespProviderRequester,
@@ -61,6 +61,14 @@ import type { PluginLogEntry } from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import type {
CurrentWorkspace,
Workspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceBootstrapResponse,
WorkspaceRole,
} from '@/app/infra/entities/workspace';
/**
*
@@ -1067,19 +1075,29 @@ export class BackendClient extends BaseHttpClient {
// ============ User API ============
public checkIfInited(): Promise<{ initialized: boolean }> {
return this.get('/api/v1/user/init');
return this.get('/api/v1/user/init', undefined, { skipWorkspace: true });
}
public initUser(user: string, password: string): Promise<object> {
return this.post('/api/v1/user/init', { user, password });
return this.post(
'/api/v1/user/init',
{ user, password },
{ skipWorkspace: true },
);
}
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
return this.post('/api/v1/user/auth', { user, password });
return this.post(
'/api/v1/user/auth',
{ user, password },
{ skipWorkspace: true },
);
}
public checkUserToken(): Promise<ApiRespUserToken> {
return this.get('/api/v1/user/check-token');
return this.get('/api/v1/user/check-token', undefined, {
skipWorkspace: true,
});
}
public resetPassword(
@@ -1087,51 +1105,165 @@ export class BackendClient extends BaseHttpClient {
recoveryKey: string,
newPassword: string,
): Promise<{ user: string }> {
return this.post('/api/v1/user/reset-password', {
user,
recovery_key: recoveryKey,
new_password: newPassword,
});
return this.post(
'/api/v1/user/reset-password',
{
user,
recovery_key: recoveryKey,
new_password: newPassword,
},
{ skipWorkspace: true },
);
}
public changePassword(
currentPassword: string,
newPassword: string,
): Promise<{ user: string }> {
return this.post('/api/v1/user/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
return this.post(
'/api/v1/user/change-password',
{
current_password: currentPassword,
new_password: newPassword,
},
{ skipWorkspace: true },
);
}
public getUserInfo(): Promise<{
account_uuid: string;
user: string;
account_type: 'local' | 'space';
has_password: boolean;
}> {
return this.get('/api/v1/user/info');
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
}
public getSpaceCredits(): Promise<{ credits: number | null }> {
return this.get('/api/v1/user/space-credits');
return this.get('/api/v1/user/space-credits', undefined, {
skipWorkspace: true,
});
}
public getAccountInfo(): Promise<{
initialized: boolean;
account_type?: 'local' | 'space';
has_password?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
}> {
return this.get('/api/v1/user/account-info');
return this.get('/api/v1/user/account-info', undefined, {
skipWorkspace: true,
});
}
// ============ Workspace API ============
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
skipWorkspace: true,
});
}
public getWorkspaces(): Promise<{ workspaces: Workspace[] }> {
return this.get('/api/v1/workspaces');
}
public getCurrentWorkspace(): Promise<CurrentWorkspace> {
return this.get('/api/v1/workspaces/current');
}
public getWorkspace(
workspaceUuid: string,
): Promise<{ workspace: Workspace }> {
return this.get(`/api/v1/workspaces/${workspaceUuid}`);
}
public getWorkspaceMembers(
workspaceUuid: string,
): Promise<{ members: WorkspaceMembership[] }> {
return this.get(`/api/v1/workspaces/${workspaceUuid}/members`);
}
public createWorkspaceInvitation(
workspaceUuid: string,
email: string,
role: Exclude<WorkspaceRole, 'owner'>,
): Promise<{ invitation: WorkspaceInvitation; token: string }> {
return this.post(`/api/v1/workspaces/${workspaceUuid}/invitations`, {
email,
role,
});
}
public getWorkspaceInvitations(
workspaceUuid: string,
): Promise<{ invitations: WorkspaceInvitation[] }> {
return this.get(`/api/v1/workspaces/${workspaceUuid}/invitations`);
}
public revokeWorkspaceInvitation(
workspaceUuid: string,
invitationUuid: string,
): Promise<object> {
return this.delete(
`/api/v1/workspaces/${workspaceUuid}/invitations/${invitationUuid}`,
);
}
public inspectWorkspaceInvitation(
token: string,
): Promise<{ invitation: WorkspaceInvitation; workspace: Workspace }> {
return this.post(
'/api/v1/invitations/inspect',
{ token },
{ skipWorkspace: true },
);
}
public acceptWorkspaceInvitation(
token: string,
registration?: { email: string; password: string },
): Promise<{ token: string; workspace_uuid: string }> {
return this.post(
'/api/v1/invitations/accept',
{
token,
registration,
},
{ skipWorkspace: true },
);
}
public updateWorkspaceMemberRole(
workspaceUuid: string,
accountUuid: string,
role: WorkspaceRole,
): Promise<{ member: WorkspaceMembership }> {
return this.patch(
`/api/v1/workspaces/${workspaceUuid}/members/${accountUuid}`,
{ role },
);
}
public removeWorkspaceMember(
workspaceUuid: string,
accountUuid: string,
): Promise<object> {
return this.delete(
`/api/v1/workspaces/${workspaceUuid}/members/${accountUuid}`,
);
}
public setPassword(
newPassword: string,
currentPassword?: string,
): Promise<{ user: string }> {
return this.post('/api/v1/user/set-password', {
new_password: newPassword,
current_password: currentPassword,
});
return this.post(
'/api/v1/user/set-password',
{
new_password: newPassword,
current_password: currentPassword,
},
{ skipWorkspace: true },
);
}
public async bindSpaceAccount(
@@ -1142,10 +1274,11 @@ export class BackendClient extends BaseHttpClient {
user: string;
account_type: 'local' | 'space';
}> {
const response = await this.instance.post('/api/v1/user/bind-space', {
code,
state,
});
const response = await this.instance.post(
'/api/v1/user/bind-space',
{ code, state },
{ skipWorkspace: true } as RequestConfig,
);
if (response.data.code !== 0) {
throw {
code: response.data.code,
@@ -1156,26 +1289,38 @@ export class BackendClient extends BaseHttpClient {
}
// ============ Space OAuth API (Redirect Flow) ============
public getSpaceAuthorizeUrl(
redirectUri: string,
state?: string,
): Promise<{
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{
authorize_url: string;
}> {
const params: Record<string, string> = { redirect_uri: redirectUri };
if (state) {
params.state = state;
}
return this.get('/api/v1/user/space/authorize-url', params);
return this.get(
'/api/v1/user/space/authorize-url',
{ redirect_uri: redirectUri },
{ skipWorkspace: true },
);
}
public async exchangeSpaceOAuthCode(code: string): Promise<{
public getSpaceBindAuthorizeUrl(redirectUri: string): Promise<{
authorize_url: string;
}> {
return this.get(
'/api/v1/user/space/bind-authorize-url',
{ redirect_uri: redirectUri },
{ skipWorkspace: true },
);
}
public async exchangeSpaceOAuthCode(
code: string,
state: string,
): Promise<{
token: string;
user: string;
}> {
const response = await this.instance.post('/api/v1/user/space/callback', {
code,
});
const response = await this.instance.post(
'/api/v1/user/space/callback',
{ code, state },
{ skipWorkspace: true } as RequestConfig,
);
if (response.data.code !== 0) {
throw {
code: response.data.code,
+31
View File
@@ -4,6 +4,12 @@ import axios, {
AxiosResponse,
AxiosError,
} from 'axios';
import {
clearActiveWorkspaceUuid,
getActiveWorkspaceUuid,
} from './workspaceContext';
import { setCurrentWorkspaceSnapshot } from './currentWorkspaceStore';
import { clearWorkspaceBootstrapSnapshot } from './workspaceBootstrapStore';
type JSONValue = string | number | boolean | JSONObject | JSONArray | null;
interface JSONObject {
@@ -21,6 +27,8 @@ export interface ResponseData<T = unknown> {
export interface RequestConfig extends AxiosRequestConfig {
isSSR?: boolean; // 服务端渲染标识
retry?: number; // 重试次数
/** Account-scoped endpoints must not receive a stale Workspace selector. */
skipWorkspace?: boolean;
}
/**
@@ -77,6 +85,17 @@ export abstract class BaseHttpClient {
if (session) {
config.headers.Authorization = `Bearer ${session}`;
}
const requestConfig = config as RequestConfig;
const workspaceUuid = getActiveWorkspaceUuid();
if (requestConfig.skipWorkspace) {
delete config.headers['X-Workspace-Id'];
delete config.headers['x-workspace-id'];
} else if (workspaceUuid) {
config.headers['X-Workspace-Id'] = workspaceUuid;
}
delete requestConfig.skipWorkspace;
}
return config;
@@ -99,6 +118,10 @@ export abstract class BaseHttpClient {
case 401:
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
clearActiveWorkspaceUuid();
setCurrentWorkspaceSnapshot(null);
clearWorkspaceBootstrapSnapshot();
if (!error.request.responseURL.includes('/check-token')) {
window.location.href = '/login';
}
@@ -187,6 +210,14 @@ export abstract class BaseHttpClient {
return this.request<T>({ method: 'put', url, data, ...config });
}
public patch<T = unknown>(
url: string,
data?: object,
config?: RequestConfig,
): Promise<T> {
return this.request<T>({ method: 'patch', url, data, ...config });
}
public delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
return this.request<T>({ method: 'delete', url, ...config });
}
@@ -0,0 +1,30 @@
import { useSyncExternalStore } from 'react';
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
let snapshot: CurrentWorkspace | null = null;
const listeners = new Set<() => void>();
export function getCurrentWorkspaceSnapshot(): CurrentWorkspace | null {
return snapshot;
}
export function setCurrentWorkspaceSnapshot(
workspace: CurrentWorkspace | null,
): void {
snapshot = workspace;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** React-safe access to the currently selected Workspace and its permissions. */
export function useCurrentWorkspace(): CurrentWorkspace | null {
return useSyncExternalStore(
subscribe,
getCurrentWorkspaceSnapshot,
getCurrentWorkspaceSnapshot,
);
}
+228 -1
View File
@@ -1,6 +1,24 @@
import { BackendClient } from './BackendClient';
import { CloudServiceClient } from './CloudServiceClient';
import { ApiRespSystemInfo } from '@/app/infra/entities/api';
import type {
CurrentWorkspace,
WorkspaceBootstrapEntry,
} from '@/app/infra/entities/workspace';
import {
clearActiveWorkspaceUuid,
getActiveWorkspaceUuid,
setActiveWorkspaceUuid,
} from './workspaceContext';
import {
getCurrentWorkspaceSnapshot,
setCurrentWorkspaceSnapshot,
} from './currentWorkspaceStore';
import {
clearWorkspaceBootstrapSnapshot,
getWorkspaceBootstrapSnapshot,
setWorkspaceBootstrapSnapshot,
} from './workspaceBootstrapStore';
// 系统信息
export const systemInfo: ApiRespSystemInfo = {
@@ -23,6 +41,7 @@ export const systemInfo: ApiRespSystemInfo = {
// 用户信息
export let userInfo: {
account_uuid: string;
user: string;
account_type: 'local' | 'space';
has_password: boolean;
@@ -109,25 +128,233 @@ export const initializeSystemInfo = async (options?: {
*
*
*/
export const initializeUserInfo = async (): Promise<void> => {
export const initializeUserInfo = async (options?: {
throwOnError?: boolean;
}): Promise<void> => {
try {
userInfo = await backendClient.getUserInfo();
if (typeof window !== 'undefined') {
localStorage.setItem('userEmail', userInfo.user);
}
} catch (error) {
console.error('Failed to initialize user info:', error);
userInfo = null;
if (options?.throwOnError) {
throw error;
}
}
};
export const initializeWorkspaceInfo = async (): Promise<void> => {
const storedWorkspaceUuid = getActiveWorkspaceUuid();
try {
const workspace = await backendClient.getCurrentWorkspace();
setCurrentWorkspaceSnapshot(workspace);
setActiveWorkspaceUuid(workspace.workspace.uuid);
} catch (error) {
setCurrentWorkspaceSnapshot(null);
clearActiveWorkspaceUuid();
// A restored Community browser session can outlive a database reset or an
// instance replacement. Its stale selector is not an authorization
// credential, and the OSS policy still has exactly one legal Workspace,
// so retry once without it. Cloud must remain explicit and fail closed.
if (storedWorkspaceUuid && systemInfo.edition === 'community') {
const workspace = await backendClient.getCurrentWorkspace();
setCurrentWorkspaceSnapshot(workspace);
setActiveWorkspaceUuid(workspace.workspace.uuid);
return;
}
throw error;
}
};
export type WorkspaceBootstrapResult =
| {
status: 'ready';
workspace: CurrentWorkspace;
workspaces: WorkspaceBootstrapEntry[];
}
| {
status: 'selection-required';
workspaces: WorkspaceBootstrapEntry[];
}
| {
status: 'unavailable';
workspaces: [];
};
export interface WorkspaceBootstrapOptions {
/** Discard any selector left by a previous Account session. */
resetSelection?: boolean;
/** Explicit intent, such as accepting an invitation. */
preferredWorkspaceUuid?: string;
/** Always show the chooser when the Account has multiple Workspaces. */
requireExplicitSelection?: boolean;
}
function clearWorkspaceSelection(): void {
setCurrentWorkspaceSnapshot(null);
clearActiveWorkspaceUuid();
}
/**
* Store a new Account token without carrying Workspace state across Accounts.
* Call bootstrapWorkspaceSession immediately afterwards.
*/
export function beginAuthenticatedSession(
token: string,
userEmail?: string,
): void {
userInfo = null;
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
if (typeof window === 'undefined') return;
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
localStorage.setItem('token', token);
if (userEmail) localStorage.setItem('userEmail', userEmail);
}
async function initializeSelectedWorkspace(
workspaceUuid: string,
workspaces: WorkspaceBootstrapEntry[],
): Promise<WorkspaceBootstrapResult> {
setActiveWorkspaceUuid(workspaceUuid);
try {
await Promise.all([
initializeUserInfo({ throwOnError: true }),
initializeWorkspaceInfo(),
]);
} catch (error) {
clearWorkspaceSelection();
throw error;
}
const workspace = getCurrentWorkspaceSnapshot();
if (!workspace) {
clearWorkspaceSelection();
throw new Error('Selected Workspace could not be initialized');
}
return { status: 'ready', workspace, workspaces };
}
/**
* Resolve Account membership before any Workspace-scoped request is made.
* A singleton is selected automatically; multiple Workspaces require explicit
* user intent unless a still-valid selector already exists.
*/
export async function bootstrapWorkspaceSession(
options: WorkspaceBootstrapOptions = {},
): Promise<WorkspaceBootstrapResult> {
if (options.resetSelection) {
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
}
const response = await backendClient.getWorkspaceBootstrap();
const workspaces = response.workspaces;
setWorkspaceBootstrapSnapshot(workspaces);
if (workspaces.length === 0) {
clearWorkspaceSelection();
return { status: 'unavailable', workspaces: [] };
}
const preferredWorkspace = options.preferredWorkspaceUuid
? workspaces.find(
(entry) => entry.workspace.uuid === options.preferredWorkspaceUuid,
)
: undefined;
if (options.preferredWorkspaceUuid && !preferredWorkspace) {
clearWorkspaceSelection();
return { status: 'selection-required', workspaces };
}
const activeWorkspace = !options.requireExplicitSelection
? workspaces.find(
(entry) => entry.workspace.uuid === getActiveWorkspaceUuid(),
)
: undefined;
const selectedWorkspace =
preferredWorkspace ??
(workspaces.length === 1 ? workspaces[0] : activeWorkspace);
if (!selectedWorkspace) {
clearWorkspaceSelection();
return { status: 'selection-required', workspaces };
}
return initializeSelectedWorkspace(
selectedWorkspace.workspace.uuid,
workspaces,
);
}
/** Revalidate and activate an explicit choice made on the chooser page. */
export async function selectWorkspace(
workspaceUuid: string,
): Promise<WorkspaceBootstrapResult> {
const response = await backendClient.getWorkspaceBootstrap();
setWorkspaceBootstrapSnapshot(response.workspaces);
const selected = response.workspaces.find(
(entry) => entry.workspace.uuid === workspaceUuid,
);
if (!selected) {
clearWorkspaceSelection();
if (response.workspaces.length === 0) {
return { status: 'unavailable', workspaces: [] };
}
return { status: 'selection-required', workspaces: response.workspaces };
}
return initializeSelectedWorkspace(
selected.workspace.uuid,
response.workspaces,
);
}
/** Clear in-memory Workspace snapshots before reloading into a new scope. */
export function switchWorkspaceAndReload(workspaceUuid: string): void {
const isAvailable = getWorkspaceBootstrapSnapshot().some(
(entry) => entry.workspace.uuid === workspaceUuid,
);
if (!isAvailable || workspaceUuid === getActiveWorkspaceUuid()) return;
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
setActiveWorkspaceUuid(workspaceUuid);
window.location.replace('/home/monitoring');
}
/**
*
*
*/
export const clearUserInfo = (): void => {
userInfo = null;
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
};
export {
clearActiveWorkspaceUuid,
clearPendingInvitationToken,
getActiveWorkspaceUuid,
getPendingInvitationToken,
setActiveWorkspaceUuid,
setPendingInvitationToken,
} from './workspaceContext';
// 导出类型,以便其他地方使用
export type { ResponseData, RequestConfig } from './BaseHttpClient';
export { BaseHttpClient } from './BaseHttpClient';
export { BackendClient } from './BackendClient';
export { CloudServiceClient } from './CloudServiceClient';
export {
getCurrentWorkspaceSnapshot,
useCurrentWorkspace,
} from './currentWorkspaceStore';
export {
getWorkspaceBootstrapSnapshot,
useWorkspaceBootstrap,
} from './workspaceBootstrapStore';
@@ -0,0 +1,35 @@
import { useSyncExternalStore } from 'react';
import type { WorkspaceBootstrapEntry } from '@/app/infra/entities/workspace';
let snapshot: WorkspaceBootstrapEntry[] = [];
const listeners = new Set<() => void>();
export function getWorkspaceBootstrapSnapshot(): WorkspaceBootstrapEntry[] {
return snapshot;
}
export function setWorkspaceBootstrapSnapshot(
workspaces: WorkspaceBootstrapEntry[],
): void {
snapshot = workspaces;
listeners.forEach((listener) => listener());
}
export function clearWorkspaceBootstrapSnapshot(): void {
setWorkspaceBootstrapSnapshot([]);
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** React-safe access to the Account's selectable Workspaces. */
export function useWorkspaceBootstrap(): WorkspaceBootstrapEntry[] {
return useSyncExternalStore(
subscribe,
getWorkspaceBootstrapSnapshot,
getWorkspaceBootstrapSnapshot,
);
}
@@ -0,0 +1,54 @@
export const ACTIVE_WORKSPACE_STORAGE_KEY = 'langbot_active_workspace_uuid';
export const PENDING_INVITATION_TOKEN_KEY = 'langbot_pending_invitation_token';
let activeWorkspaceUuid: string | null = null;
function readStoredWorkspaceUuid(): string | null {
if (typeof window === 'undefined') return null;
const value = localStorage.getItem(ACTIVE_WORKSPACE_STORAGE_KEY)?.trim();
return value || null;
}
export function getActiveWorkspaceUuid(): string | null {
if (activeWorkspaceUuid) return activeWorkspaceUuid;
activeWorkspaceUuid = readStoredWorkspaceUuid();
return activeWorkspaceUuid;
}
export function setActiveWorkspaceUuid(workspaceUuid: string): void {
const normalized = workspaceUuid.trim();
if (!normalized) {
throw new Error('Workspace UUID cannot be empty');
}
activeWorkspaceUuid = normalized;
if (typeof window !== 'undefined') {
localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, normalized);
}
}
export function clearActiveWorkspaceUuid(): void {
activeWorkspaceUuid = null;
if (typeof window !== 'undefined') {
localStorage.removeItem(ACTIVE_WORKSPACE_STORAGE_KEY);
}
}
export function getPendingInvitationToken(): string | null {
if (typeof window === 'undefined') return null;
const token = sessionStorage.getItem(PENDING_INVITATION_TOKEN_KEY)?.trim();
return token || null;
}
export function setPendingInvitationToken(token: string): void {
const normalized = token.trim();
if (!normalized) throw new Error('Invitation token cannot be empty');
if (typeof window !== 'undefined') {
sessionStorage.setItem(PENDING_INVITATION_TOKEN_KEY, normalized);
}
}
export function clearPendingInvitationToken(): void {
if (typeof window !== 'undefined') {
sessionStorage.removeItem(PENDING_INVITATION_TOKEN_KEY);
}
}
+19 -1
View File
@@ -2,6 +2,8 @@
* WebSocket客户端类
* WebSocket连接和消息处理
*/
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export interface WebSocketMessage {
id: number;
role: 'user' | 'assistant';
@@ -91,7 +93,22 @@ export class WebSocketClient {
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.isConnecting = false;
this.startHeartbeat();
const token = this.token || localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
if (!token || !workspaceUuid) {
const error = new Error('WebSocket认证信息缺失');
this.onErrorCallback?.(error);
this.ws?.close();
reject(error);
return;
}
this.ws?.send(
JSON.stringify({
type: 'authenticate',
token,
workspace_uuid: workspaceUuid,
}),
);
};
// 接收消息
@@ -103,6 +120,7 @@ export class WebSocketClient {
// 第一次连接成功
if (data.type === 'connected' && data.connection_id) {
this.connectionId = data.connection_id;
this.startHeartbeat();
resolve(data.connection_id);
}
} catch (error) {
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useState } from 'react';
import { AlertCircle, CheckCircle2, Loader2, Lock, Mail } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { LanguageSelector } from '@/components/ui/language-selector';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import type {
Workspace,
WorkspaceInvitation,
} from '@/app/infra/entities/workspace';
import {
backendClient,
beginAuthenticatedSession,
bootstrapWorkspaceSession,
clearPendingInvitationToken,
clearUserInfo,
getPendingInvitationToken,
setPendingInvitationToken,
} from '@/app/infra/http';
type InvitationView = {
invitation: WorkspaceInvitation;
workspace: Workspace;
};
function captureInvitationTokenFromFragment(): string | null {
if (typeof window === 'undefined') return null;
const fragment = new URLSearchParams(window.location.hash.slice(1));
const token = fragment.get('token')?.trim();
if (!token) return getPendingInvitationToken();
setPendingInvitationToken(token);
window.history.replaceState(
null,
document.title,
`${window.location.pathname}${window.location.search}`,
);
return token;
}
export default function AcceptInvitationPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [invitationHash, setInvitationHash] = useState(() =>
typeof window === 'undefined' ? '' : window.location.hash,
);
const [token, setToken] = useState<string | null>(null);
const [view, setView] = useState<InvitationView | null>(null);
const [status, setStatus] = useState<
'loading' | 'ready' | 'submitting' | 'success' | 'error'
>('loading');
const [errorMessage, setErrorMessage] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
useEffect(() => {
const handleHashChange = () => setInvitationHash(window.location.hash);
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
useEffect(() => {
setStatus('loading');
setView(null);
setErrorMessage('');
const invitationToken = captureInvitationTokenFromFragment();
setToken(invitationToken);
if (!invitationToken) {
setErrorMessage(t('workspace.invitationMissing'));
setStatus('error');
return;
}
let cancelled = false;
backendClient
.inspectWorkspaceInvitation(invitationToken)
.then((response) => {
if (cancelled) return;
setView(response);
setStatus('ready');
})
.catch((error: { code?: string; msg?: string }) => {
if (cancelled) return;
clearPendingInvitationToken();
const key =
error.code === 'invitation_expired'
? 'workspace.invitationExpired'
: error.code === 'invitation_revoked'
? 'workspace.invitationAlreadyRevoked'
: error.code === 'invitation_used'
? 'workspace.invitationAlreadyUsed'
: 'workspace.invitationInvalid';
setErrorMessage(t(key));
setStatus('error');
});
return () => {
cancelled = true;
};
}, [invitationHash, t]);
async function finishAcceptance(registration?: {
email: string;
password: string;
}) {
if (!token) return;
setStatus('submitting');
setErrorMessage('');
try {
const response = await backendClient.acceptWorkspaceInvitation(
token,
registration,
);
beginAuthenticatedSession(
response.token,
registration?.email ?? view?.invitation.normalized_email,
);
clearPendingInvitationToken();
const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: response.workspace_uuid,
});
if (workspaceResult.status !== 'ready') {
throw new Error('Accepted Workspace could not be initialized');
}
setStatus('success');
toast.success(t('workspace.invitationAccepted'));
window.setTimeout(() => navigate('/home', { replace: true }), 600);
} catch (error) {
const apiError = error as { code?: string; msg?: string };
if (apiError.code === 'account_exists_login_required') {
setStatus('ready');
setErrorMessage(t('workspace.existingAccountLoginRequired'));
return;
}
setStatus('error');
setErrorMessage(
apiError.code === 'invitation_email_mismatch'
? t('workspace.invitationEmailMismatch')
: t('workspace.invitationAcceptFailed'),
);
}
}
function registerAndAccept() {
if (!view) return;
if (password.length < 8) {
setErrorMessage(t('workspace.passwordMinimum'));
return;
}
if (password !== confirmPassword) {
setErrorMessage(t('workspace.passwordMismatch'));
return;
}
void finishAcceptance({
email: view.invitation.normalized_email,
password,
});
}
function returnToLogin() {
clearUserInfo();
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
}
navigate('/login', { replace: true });
}
const hasLoginToken =
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
<Card className="w-full max-w-md shadow-lg dark:shadow-white/10">
<CardHeader>
<div className="mb-4 flex items-center justify-between">
<ThemeToggle />
<LanguageSelector />
</div>
<img
src={langbotIcon}
alt="LangBot"
className="mx-auto mb-3 size-14"
/>
<CardTitle className="text-center">
{t('workspace.acceptInvitation')}
</CardTitle>
<CardDescription className="text-center">
{view
? t('workspace.invitedToWorkspace', {
workspace: view.workspace.name,
})
: t('workspace.checkingInvitation')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{status === 'loading' && (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin" />
</div>
)}
{status === 'success' && (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<CheckCircle2 className="size-9 text-green-600" />
<p>{t('workspace.invitationAccepted')}</p>
</div>
)}
{status === 'error' && (
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
<Button
variant="outline"
className="w-full"
onClick={returnToLogin}
>
{t('workspace.backToLogin')}
</Button>
</div>
)}
{(status === 'ready' || status === 'submitting') && view && (
<div className="space-y-4">
{errorMessage && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
{hasLoginToken ? (
<Button
className="w-full"
disabled={status === 'submitting'}
onClick={() => void finishAcceptance()}
>
{status === 'submitting' && (
<Loader2 className="size-4 animate-spin" />
)}
{t('workspace.acceptAsCurrentAccount')}
</Button>
) : (
<>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="invite-email"
>
{t('common.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
id="invite-email"
value={view.invitation.normalized_email}
readOnly
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="invite-password"
>
{t('common.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
id="invite-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="pl-10"
autoComplete="new-password"
/>
</div>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="invite-password-confirm"
>
{t('workspace.confirmPassword')}
</label>
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.target.value)
}
autoComplete="new-password"
/>
</div>
<Button
className="w-full"
disabled={status === 'submitting'}
onClick={registerAndAccept}
>
{status === 'submitting' && (
<Loader2 className="size-4 animate-spin" />
)}
{t('workspace.registerAndAccept')}
</Button>
<Button
variant="ghost"
className="w-full"
disabled={status === 'submitting'}
onClick={() => navigate('/login?invitation=1')}
>
{t('workspace.alreadyHaveAccount')}
</Button>
</>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+48 -21
View File
@@ -20,7 +20,13 @@ import {
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
import { httpClient, initializeUserInfo } from '@/app/infra/http';
import {
beginAuthenticatedSession,
bootstrapWorkspaceSession,
clearPendingInvitationToken,
getPendingInvitationToken,
httpClient,
} from '@/app/infra/http';
import { useNavigate } from 'react-router-dom';
import {
Mail,
@@ -43,14 +49,12 @@ const formSchema = (t: (key: string) => string) =>
password: z.string().min(1, t('common.emptyPassword')),
});
type AccountType = 'local' | 'space';
export default function Login() {
const navigate = useNavigate();
const { t } = useTranslation();
const [spaceLoading, setSpaceLoading] = useState(false);
const [accountType, setAccountType] = useState<AccountType | null>(null);
const [hasPassword, setHasPassword] = useState(false);
const [showLocalLogin, setShowLocalLogin] = useState(false);
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
@@ -75,8 +79,8 @@ export default function Login() {
navigate('/register');
return;
}
setAccountType(res.account_type || 'local');
setHasPassword(res.has_password || false);
setShowLocalLogin(res.password_login_enabled !== false);
setShowSpaceLogin(res.space_login_enabled !== false);
setLoading(false);
// Also check if already logged in
@@ -109,15 +113,46 @@ export default function Login() {
function checkIfAlreadyLoggedIn() {
httpClient
.checkUserToken()
.then((res) => {
.then(async (res) => {
if (res.token) {
localStorage.setItem('token', res.token);
navigate('/home');
await finishLogin(res.token);
}
})
.catch(() => {});
}
async function finishLogin(token: string, username?: string) {
beginAuthenticatedSession(token, username);
const invitationToken = getPendingInvitationToken();
let preferredWorkspaceUuid: string | undefined;
if (invitationToken) {
try {
const response =
await httpClient.acceptWorkspaceInvitation(invitationToken);
beginAuthenticatedSession(response.token, username);
preferredWorkspaceUuid = response.workspace_uuid;
clearPendingInvitationToken();
} catch {
navigate('/invitations/accept', { replace: true });
toast.error(t('workspace.invitationAcceptFailed'));
return;
}
}
const result = await bootstrapWorkspaceSession({
preferredWorkspaceUuid,
});
if (result.status === 'selection-required') {
navigate('/workspaces/select?returnTo=%2Fhome', { replace: true });
return;
}
if (result.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
navigate('/home');
}
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
handleLogin(values.email, values.password);
}
@@ -126,10 +161,7 @@ export default function Login() {
httpClient
.authUser(username, password)
.then(async (res) => {
localStorage.setItem('token', res.token);
localStorage.setItem('userEmail', username);
await initializeUserInfo();
navigate('/home');
await finishLogin(res.token, username);
toast.success(t('common.loginSuccess'));
})
.catch(() => {
@@ -211,11 +243,6 @@ export default function Login() {
);
}
// Determine what to show based on account type
const showLocalLogin =
accountType === 'local' || (accountType === 'space' && hasPassword);
const showSpaceLogin = accountType === 'space';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
@@ -237,7 +264,7 @@ export default function Login() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Space Login - only show for space accounts */}
{/* Space and password login are per-account capabilities. */}
{showSpaceLogin && (
<div className="space-y-3">
<Button
@@ -270,7 +297,7 @@ export default function Login() {
</div>
)}
{/* Local Account Login - show for local accounts or space accounts with password */}
{/* Password login remains available to every account with a password. */}
{showLocalLogin && (
<Form {...form}>
<form
+12 -4
View File
@@ -18,7 +18,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import {
userInfo,
systemInfo,
initializeUserInfo,
bootstrapWorkspaceSession,
initializeSystemInfo,
} from '@/app/infra/http';
import {
@@ -129,8 +129,16 @@ export default function WizardPage() {
let cancelled = false;
(async () => {
try {
// Initialize user/system info (wizard is outside /home layout)
await Promise.all([initializeUserInfo(), initializeSystemInfo()]);
// Resolve the Account's Workspace before loading scoped wizard data.
const workspaceResult = await bootstrapWorkspaceSession();
if (workspaceResult.status === 'selection-required') {
navigate('/workspaces/select?returnTo=%2Fwizard', { replace: true });
return;
}
if (workspaceResult.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
await initializeSystemInfo({ throwOnError: true });
const [adaptersResp, metadataResp] = await Promise.all([
httpClient.getAdapters(),
@@ -191,7 +199,7 @@ export default function WizardPage() {
return () => {
cancelled = true;
};
}, [t]);
}, [navigate, t]);
// ---- Derived data ----
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { AlertCircle, ArrowRight, Building2, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import {
bootstrapWorkspaceSession,
clearUserInfo,
selectWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { LanguageSelector } from '@/components/ui/language-selector';
import { ThemeToggle } from '@/components/ui/theme-toggle';
function safeReturnPath(value: string | null): string {
if (
value &&
value.startsWith('/') &&
!value.startsWith('//') &&
(value === '/wizard' || value.startsWith('/home'))
) {
return value;
}
return '/home';
}
export default function WorkspaceSelectPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const workspaces = useWorkspaceBootstrap();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [selectingUuid, setSelectingUuid] = useState<string | null>(null);
const returnTo = safeReturnPath(searchParams.get('returnTo'));
const loadWorkspaces = useCallback(async () => {
setLoading(true);
setError(false);
try {
const result = await bootstrapWorkspaceSession({
requireExplicitSelection: true,
});
if (result.status === 'ready') {
navigate(returnTo, { replace: true });
} else if (result.status === 'unavailable') {
setError(true);
}
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [navigate, returnTo]);
useEffect(() => {
void loadWorkspaces();
}, [loadWorkspaces]);
async function handleSelect(workspaceUuid: string) {
setSelectingUuid(workspaceUuid);
setError(false);
try {
const result = await selectWorkspace(workspaceUuid);
if (result.status === 'ready') {
navigate(returnTo, { replace: true });
return;
}
setError(true);
} catch {
setError(true);
} finally {
setSelectingUuid(null);
}
}
function handleLogout() {
clearUserInfo();
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
navigate('/login', { replace: true });
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
<Card className="w-full max-w-xl shadow-lg dark:shadow-white/10">
<CardHeader>
<div className="mb-5 flex items-center justify-between">
<ThemeToggle />
<LanguageSelector />
</div>
<img
src={langbotIcon}
alt="LangBot"
className="mx-auto mb-4 size-16"
/>
<CardTitle
className="text-center text-2xl"
role="heading"
aria-level={1}
>
{t('workspace.selectTitle')}
</CardTitle>
<CardDescription className="text-center">
{t('workspace.selectDescription')}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="size-6 animate-spin" />
</div>
) : (
<div className="space-y-3">
{error && (
<div className="flex items-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" />
<span>{t('workspace.selectionLoadFailed')}</span>
<Button
type="button"
variant="outline"
size="sm"
className="ml-auto"
onClick={() => void loadWorkspaces()}
>
{t('common.retry')}
</Button>
</div>
)}
{workspaces.map((entry) => {
const selecting = selectingUuid === entry.workspace.uuid;
return (
<button
key={entry.workspace.uuid}
type="button"
className="flex w-full items-center gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent disabled:cursor-wait disabled:opacity-70"
onClick={() => void handleSelect(entry.workspace.uuid)}
disabled={selectingUuid !== null}
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Building2 className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{entry.workspace.name}
</span>
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary">
{t(`workspace.roles.${entry.membership.role}`)}
</Badge>
<span>
{entry.workspace.type === 'personal'
? t('workspace.types.personal')
: t('workspace.types.team')}
</span>
</span>
</span>
{selecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ArrowRight className="size-4 text-muted-foreground" />
)}
</button>
);
})}
<Button
type="button"
variant="ghost"
className="w-full"
onClick={handleLogout}
>
{t('common.logout')}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+73
View File
@@ -167,6 +167,7 @@ const enUS = {
actions: 'Actions',
apiKeyCreatedMessage:
'Please copy this API key, if the button is invalid, please copy manually.',
apiKeyStoredSecurely: 'Secret shown only when created',
none: 'None',
more: 'More ({{count}})',
less: 'Less',
@@ -1274,6 +1275,77 @@ const enUS = {
spaceEmailMismatch:
'Space login email does not match the local account email',
},
workspace: {
title: 'Workspace',
description: 'Manage members, roles, and invitation links',
selectTitle: 'Choose a Workspace',
selectDescription: 'Select where you want to continue in LangBot.',
selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace',
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.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
inviteMember: 'Invite a member',
inviteDescription:
'Create a one-time link to add another user to this Workspace.',
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning:
'Copy this link now. The secret is shown only once and is not stored by LangBot.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
expiresAt: 'Expires {{date}}',
revokeInvitation: 'Revoke invitation',
invitationRevoked: 'Invitation revoked',
invitationRevokeFailed: 'Failed to revoke invitation',
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'The invitation secret is missing from this link.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
invitationInvalid: 'This invitation is invalid or no longer available.',
invitationAccepted: 'Invitation accepted',
invitationAcceptFailed: 'Failed to accept invitation',
invitationEmailMismatch:
'This invitation belongs to a different email address.',
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
passwordMinimum: 'Password must contain at least 8 characters.',
passwordMismatch: 'The passwords do not match.',
backToLogin: 'Back to sign in',
memberUpdated: 'Member role updated',
memberUpdateFailed: 'Failed to update member role',
removeMember: 'Remove member',
removeMemberConfirm: 'Remove this member from the Workspace?',
memberRemoved: 'Member removed',
memberRemoveFailed: 'Failed to remove member',
transferOwnership: 'Transfer ownership',
types: {
personal: 'Personal',
team: 'Team',
},
roles: {
owner: 'Owner',
admin: 'Admin',
developer: 'Developer',
operator: 'Operator',
viewer: 'Viewer',
},
},
monitoring: {
title: 'Dashboard',
description: 'Monitor bot activities, LLM calls, and system performance',
@@ -1525,6 +1597,7 @@ const enUS = {
settingsDialog: {
title: 'Settings',
nav: {
workspace: 'Workspace',
models: 'Models',
api: 'API',
storage: 'Storage',
+72
View File
@@ -169,6 +169,7 @@ const jaJP = {
actions: 'アクション',
apiKeyCreatedMessage:
'この API キーをコピーしてください。もしボタンが無効な場合は手動でコピーしてください。',
apiKeyStoredSecurely: 'シークレットは作成時のみ表示されます',
none: 'なし',
more: 'もっと見る ({{count}})',
less: '折りたたむ',
@@ -1279,6 +1280,76 @@ const jaJP = {
spaceEmailMismatch:
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
},
workspace: {
title: 'ワークスペース',
description: 'メンバー、ロール、招待リンクを管理します',
selectTitle: 'ワークスペースを選択',
selectDescription: 'LangBot で使用するワークスペースを選択してください。',
selectionLoadFailed:
'ワークスペースを読み込めませんでした。もう一度お試しください。',
switchWorkspace: 'ワークスペースを切り替え',
ossSingletonDescription:
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
cloudManagedDescription:
'このワークスペースのメンバー、招待、請求は LangBot Cloud ポータルで管理されます。',
loadFailed: 'ワークスペース情報の読み込みに失敗しました',
members: 'メンバー',
you: 'あなた',
inviteMember: 'メンバーを招待',
inviteDescription:
'現在のワークスペースにユーザーを追加する一度限りのリンクを作成します。',
emailPlaceholder: 'member@example.com',
createInvitation: '招待を作成',
invitationCreated: '招待を作成しました',
invitationCreateFailed: '招待の作成に失敗しました',
oneTimeLinkWarning:
'このリンクを今すぐコピーしてください。シークレットは一度だけ表示され、LangBotには保存されません。',
copyInvitation: '招待リンクをコピー',
invitationCopied: '招待リンクをコピーしました',
pendingInvitations: '保留中の招待',
expiresAt: '{{date}} に期限切れ',
revokeInvitation: '招待を取り消す',
invitationRevoked: '招待を取り消しました',
invitationRevokeFailed: '招待の取り消しに失敗しました',
acceptInvitation: '招待を承認',
invitedToWorkspace: '{{workspace}} に招待されました',
checkingInvitation: '招待を確認しています...',
invitationMissing: '招待リンクにシークレットがありません。',
invitationExpired: 'この招待は期限切れです。',
invitationAlreadyRevoked: 'この招待は取り消されました。',
invitationAlreadyUsed: 'この招待はすでに使用されています。',
invitationInvalid: 'この招待は無効か、利用できなくなっています。',
invitationAccepted: '招待を承認しました',
invitationAcceptFailed: '招待の承認に失敗しました',
invitationEmailMismatch: 'この招待は別のメールアドレスに送られたものです。',
existingAccountLoginRequired:
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
acceptAsCurrentAccount: '現在のアカウントで承認',
registerAndAccept: 'アカウントを作成して承認',
alreadyHaveAccount: 'アカウントを持っています',
confirmPassword: 'パスワードを確認',
passwordMinimum: 'パスワードは8文字以上にしてください。',
passwordMismatch: 'パスワードが一致しません。',
backToLogin: 'ログインに戻る',
memberUpdated: 'メンバーのロールを更新しました',
memberUpdateFailed: 'メンバーのロール更新に失敗しました',
removeMember: 'メンバーを削除',
removeMemberConfirm: 'このメンバーをワークスペースから削除しますか?',
memberRemoved: 'メンバーを削除しました',
memberRemoveFailed: 'メンバーの削除に失敗しました',
transferOwnership: '所有権を移譲',
types: {
personal: '個人',
team: 'チーム',
},
roles: {
owner: '所有者',
admin: '管理者',
developer: '開発者',
operator: 'オペレーター',
viewer: '閲覧者',
},
},
monitoring: {
title: 'ダッシュボード',
description:
@@ -1531,6 +1602,7 @@ const jaJP = {
settingsDialog: {
title: '設定',
nav: {
workspace: 'ワークスペース',
models: 'モデル',
api: 'API',
storage: 'ストレージ',
+69
View File
@@ -156,6 +156,7 @@ const zhHans = {
webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统',
actions: '操作',
apiKeyCreatedMessage: '请复制此 API 密钥,若按钮无效,请手动复制。',
apiKeyStoredSecurely: '密钥仅在创建时显示',
none: '无',
more: '更多 ({{count}})',
less: '收起',
@@ -1210,6 +1211,73 @@ const zhHans = {
setPasswordHint: '设置密码后可使用邮箱密码登录',
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
},
workspace: {
title: '工作区',
description: '管理成员、角色与邀请链接',
selectTitle: '选择工作区',
selectDescription: '选择你要进入的 LangBot 工作区。',
selectionLoadFailed: '无法加载你的工作区,请重试。',
switchWorkspace: '切换工作区',
ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription:
'此工作区的成员、邀请与计费由 LangBot Cloud 控制台统一管理。',
loadFailed: '加载工作区信息失败',
members: '成员',
you: '你',
inviteMember: '邀请成员',
inviteDescription: '创建一次性链接,将其他用户加入当前工作区。',
emailPlaceholder: 'member@example.com',
createInvitation: '创建邀请',
invitationCreated: '邀请已创建',
invitationCreateFailed: '创建邀请失败',
oneTimeLinkWarning:
'请立即复制此链接。密钥只显示一次,LangBot 不会保存明文。',
copyInvitation: '复制邀请链接',
invitationCopied: '邀请链接已复制',
pendingInvitations: '待接受邀请',
expiresAt: '{{date}} 过期',
revokeInvitation: '撤销邀请',
invitationRevoked: '邀请已撤销',
invitationRevokeFailed: '撤销邀请失败',
acceptInvitation: '接受邀请',
invitedToWorkspace: '你已受邀加入 {{workspace}}',
checkingInvitation: '正在验证邀请…',
invitationMissing: '邀请链接中缺少密钥。',
invitationExpired: '此邀请已过期。',
invitationAlreadyRevoked: '此邀请已被撤销。',
invitationAlreadyUsed: '此邀请已被使用。',
invitationInvalid: '此邀请无效或已不可用。',
invitationAccepted: '已接受邀请',
invitationAcceptFailed: '接受邀请失败',
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
acceptAsCurrentAccount: '使用当前账户接受',
registerAndAccept: '创建账户并接受',
alreadyHaveAccount: '我已有账户',
confirmPassword: '确认密码',
passwordMinimum: '密码至少需要 8 个字符。',
passwordMismatch: '两次输入的密码不一致。',
backToLogin: '返回登录',
memberUpdated: '成员角色已更新',
memberUpdateFailed: '更新成员角色失败',
removeMember: '移除成员',
removeMemberConfirm: '确定将此成员移出工作区吗?',
memberRemoved: '成员已移除',
memberRemoveFailed: '移除成员失败',
transferOwnership: '转让所有权',
types: {
personal: '个人',
team: '团队',
},
roles: {
owner: '所有者',
admin: '管理员',
developer: '开发者',
operator: '运维人员',
viewer: '查看者',
},
},
monitoring: {
title: '仪表盘',
description: '查看机器人活动、LLM调用和系统性能',
@@ -1460,6 +1528,7 @@ const zhHans = {
settingsDialog: {
title: '设置',
nav: {
workspace: '工作区',
models: '模型',
api: 'API',
storage: '存储',
+10
View File
@@ -26,6 +26,8 @@ import ErrorPage from '@/components/ErrorPage';
import BackendUnavailablePage from '@/components/BackendUnavailablePage';
import PluginPagesPage from '@/app/home/plugin-pages/page';
import RootLayout from '@/app/RootLayout';
import AcceptInvitationPage from '@/app/invitations/accept/page';
import WorkspaceSelectPage from '@/app/workspaces/select/page';
const Loading = () => <div>Loading...</div>;
@@ -62,6 +64,14 @@ export const router = createBrowserRouter([
</ResetPasswordLayout>
),
},
{
path: '/invitations/accept',
element: <AcceptInvitationPage />,
},
{
path: '/workspaces/select',
element: <WorkspaceSelectPage />,
},
{
path: '/wizard',
element: <WizardPage />,
+115 -3
View File
@@ -67,7 +67,32 @@ interface BotMock {
updated_at: string;
}
export interface WorkspaceEntryMock {
workspace: {
uuid: string;
instance_uuid: string;
name: string;
slug: string;
type: 'personal' | 'team';
status: 'active';
source: 'local' | 'cloud_projection';
};
membership: {
uuid: string;
workspace_uuid: string;
account_uuid: string;
email: string;
role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
status: 'active';
joined_at: string;
created_at: string;
};
permissions: string[];
placement_generation: number;
}
interface LangBotApiMockState {
authenticated: boolean;
bots: BotMock[];
counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[];
@@ -78,6 +103,7 @@ interface LangBotApiMockState {
sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>;
skills: SkillMock[];
workspaces: WorkspaceEntryMock[];
}
function ok(data: unknown) {
@@ -109,6 +135,58 @@ function now() {
return new Date().toISOString();
}
export function makeWorkspaceEntry(
uuid: string,
name: string,
source: 'local' | 'cloud_projection' = 'cloud_projection',
): WorkspaceEntryMock {
const createdAt = now();
return {
workspace: {
uuid,
instance_uuid: 'instance-playwright',
name,
slug: uuid,
type: 'team',
status: 'active',
source,
},
membership: {
uuid: `membership-${uuid}`,
workspace_uuid: uuid,
account_uuid: 'account-playwright',
email: 'admin@example.com',
role: 'owner',
status: 'active',
joined_at: createdAt,
created_at: createdAt,
},
permissions: [
'api_key.manage',
'audit.view',
'member.invite',
'member.remove',
'member.update_role',
'member.view',
'owner.transfer',
'provider_secret.manage',
'resource.manage',
'resource.view',
'runtime.operate',
'workspace.view',
],
placement_generation: 1,
};
}
function defaultWorkspaceEntry(): WorkspaceEntryMock {
return makeWorkspaceEntry(
'workspace-playwright',
'Playwright Workspace',
'local',
);
}
function nextId(state: LangBotApiMockState, prefix: string) {
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
return `${prefix}-${state.counters[prefix]}`;
@@ -423,21 +501,33 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
if (path === '/api/v1/user/account-info') {
return fulfillJson(route, {
initialized: true,
account_type: 'local',
has_password: true,
password_login_enabled: true,
space_login_enabled: false,
});
}
if (path === '/api/v1/user/check-token') {
return fulfillJson(route, { token: '' });
return fulfillJson(route, {
token: state.authenticated ? 'playwright-token' : '',
});
}
if (path === '/api/v1/user/auth') {
state.authenticated = true;
return fulfillJson(route, { token: 'playwright-token' });
}
if (path === '/api/v1/user/space/callback') {
state.authenticated = true;
return fulfillJson(route, {
token: 'playwright-space-token',
user: 'admin@example.com',
});
}
if (path === '/api/v1/user/info') {
return fulfillJson(route, {
account_uuid: 'account-playwright',
user: 'admin@example.com',
account_type: 'local',
has_password: true,
@@ -448,6 +538,24 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { credits: null });
}
if (path === '/api/v1/workspaces/bootstrap') {
return fulfillJson(route, { workspaces: state.workspaces });
}
if (path === '/api/v1/workspaces/current') {
const selectedWorkspaceUuid = request.headers()['x-workspace-id'];
const entry = state.workspaces.find(
(item) => item.workspace.uuid === selectedWorkspaceUuid,
);
return fulfillJson(route, entry || state.workspaces[0]);
}
if (path === '/api/v1/workspaces') {
return fulfillJson(route, {
workspaces: state.workspaces.map((entry) => entry.workspace),
});
}
if (path === '/api/v1/platform/adapters') {
return fulfillJson(route, { adapters: mockAdapters() });
}
@@ -951,6 +1059,7 @@ export async function installLangBotApiMocks(
sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord;
workspaces?: WorkspaceEntryMock[];
} = {},
) {
const {
@@ -960,8 +1069,10 @@ export async function installLangBotApiMocks(
sessionAnalyses,
sessionMessages,
storage = {},
workspaces = [defaultWorkspaceEntry()],
} = options;
const state: LangBotApiMockState = {
authenticated,
bots: [],
counters: {},
knowledgeBases: [],
@@ -972,6 +1083,7 @@ export async function installLangBotApiMocks(
sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {},
skills: [],
workspaces,
};
await page.addInitScript(
+55
View File
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('terminal invitation errors refresh on a new fragment and allow account switching', async ({
page,
}) => {
await installLangBotApiMocks(page, {
storage: {
token: 'playwright-token',
userEmail: 'another-account@example.com',
},
});
await page.route('**/api/v1/invitations/inspect', async (route) => {
const body = JSON.parse(route.request().postData() || '{}') as {
token?: string;
};
const code =
body.token === 'revoked-invitation'
? 'invitation_revoked'
: 'invitation_used';
await route.fulfill({
status: 410,
contentType: 'application/json',
body: JSON.stringify({ code, msg: code }),
});
});
await page.goto('/invitations/accept#token=used-invitation');
await expect(
page.getByText('This invitation was already used.'),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
sessionStorage.getItem('langbot_pending_invitation_token'),
),
)
.toBeNull();
await page.evaluate(() => {
window.location.hash = 'token=revoked-invitation';
});
await expect(page.getByText('This invitation was revoked.')).toBeVisible();
await page.getByRole('button', { name: 'Back to sign in' }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByText('Welcome')).toBeVisible();
expect(
await page.evaluate(() => ({
token: localStorage.getItem('token'),
userEmail: localStorage.getItem('userEmail'),
})),
).toEqual({ token: null, userEmail: null });
});
+125 -2
View File
@@ -1,6 +1,9 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
test('local account login reaches the authenticated home shell', async ({
page,
@@ -14,9 +17,129 @@ test('local account login reaches the authenticated home shell', async ({
await page.getByPlaceholder('Enter password').fill('password');
await page.getByRole('button', { name: 'Login with password' }).click();
await expect(page).toHaveURL(/\/home$/);
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
await expect(page.getByText('Home').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Total Messages').first()).toBeVisible();
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
});
test('an existing Account token bootstraps the singleton without a selector loop', async ({
page,
}) => {
const bootstrapWorkspaceHeaders: Array<string | undefined> = [];
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/v1/workspaces/bootstrap') {
bootstrapWorkspaceHeaders.push(request.headers()['x-workspace-id']);
}
});
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/login');
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
expect(bootstrapWorkspaceHeaders.length).toBeGreaterThan(0);
expect(bootstrapWorkspaceHeaders.every((header) => !header)).toBe(true);
});
test('multi-Workspace login waits for an explicit selection', async ({
page,
}) => {
const accountScopedRequests: { path: string; workspace?: string }[] = [];
const selectedWorkspaceHeaders: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
const workspace = request.headers()['x-workspace-id'];
if (
path === '/api/v1/user/auth' ||
path === '/api/v1/user/check-token' ||
path === '/api/v1/workspaces/bootstrap'
) {
accountScopedRequests.push({ path, workspace });
}
if (path === '/api/v1/workspaces/current' && workspace) {
selectedWorkspaceHeaders.push(workspace);
}
});
await installLangBotApiMocks(page, {
storage: {
langbot_active_workspace_uuid: 'workspace-from-another-account',
},
workspaces: [
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
],
});
await page.goto('/login');
await page.getByPlaceholder('Enter email address').fill('admin@example.com');
await page.getByPlaceholder('Enter password').fill('password');
await page.getByRole('button', { name: 'Login with password' }).click();
await expect(page).toHaveURL(/\/workspaces\/select/);
await expect(
page.getByRole('heading', { name: 'Choose a Workspace' }),
).toBeVisible();
await expect(page.getByText('Alpha Workspace')).toBeVisible();
await expect(page.getByText('Beta Workspace')).toBeVisible();
await page.getByRole('button', { name: /Beta Workspace/ }).click();
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
await expect(
page.getByRole('combobox', { name: 'Switch Workspace' }),
).toBeVisible();
expect(selectedWorkspaceHeaders).toContain('workspace-beta');
expect(accountScopedRequests.length).toBeGreaterThan(0);
expect(accountScopedRequests.every((request) => !request.workspace)).toBe(
true,
);
});
test('Space OAuth bootstraps a singleton before entering home', async ({
page,
}) => {
const selectedWorkspaceHeaders: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
const workspace = request.headers()['x-workspace-id'];
if (path === '/api/v1/workspaces/current' && workspace) {
selectedWorkspaceHeaders.push(workspace);
}
});
await installLangBotApiMocks(page);
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
timeout: 5_000,
});
expect(selectedWorkspaceHeaders).toContain('workspace-playwright');
});
test('Space OAuth sends a multi-Workspace Account to the chooser', async ({
page,
}) => {
const bootstrapWorkspaceHeaders: Array<string | undefined> = [];
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/v1/workspaces/bootstrap') {
bootstrapWorkspaceHeaders.push(request.headers()['x-workspace-id']);
}
});
await installLangBotApiMocks(page, {
workspaces: [
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
],
});
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
await expect(page).toHaveURL(/\/workspaces\/select/, { timeout: 5_000 });
await expect(page.getByText('Alpha Workspace')).toBeVisible();
await expect(page.getByText('Beta Workspace')).toBeVisible();
expect(bootstrapWorkspaceHeaders.length).toBeGreaterThan(0);
expect(bootstrapWorkspaceHeaders.every((header) => !header)).toBe(true);
});