mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 20:36:08 +00:00
feat(tenancy): implement workspace isolation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ストレージ',
|
||||
|
||||
@@ -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: '存储',
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user