mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
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,
|
||||
getPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -23,6 +28,7 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
type SpaceOAuthLoginResult = {
|
||||
token: string;
|
||||
user: string;
|
||||
workspace_uuid?: string;
|
||||
};
|
||||
|
||||
const pendingSpaceOAuthLogins = new Map<
|
||||
@@ -32,19 +38,23 @@ const pendingSpaceOAuthLogins = new Map<
|
||||
|
||||
function getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode: string,
|
||||
state: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<SpaceOAuthLoginResult> {
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
|
||||
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = httpClient
|
||||
.exchangeSpaceOAuthCode(authCode)
|
||||
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
|
||||
.finally(() => {
|
||||
pendingSpaceOAuthLogins.delete(authCode);
|
||||
pendingSpaceOAuthLogins.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingSpaceOAuthLogins.set(authCode, requestPromise);
|
||||
pendingSpaceOAuthLogins.set(requestKey, requestPromise);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
@@ -58,29 +68,57 @@ function SpaceOAuthCallbackContent() {
|
||||
'loading' | 'confirm' | 'success' | 'error'
|
||||
>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [terminalErrorCode, setTerminalErrorCode] = useState<
|
||||
'space_account_not_registered' | 'space_account_binding_required' | null
|
||||
>(null);
|
||||
const [isBindMode, setIsBindMode] = useState(false);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [localEmail, setLocalEmail] = useState<string>('');
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (authCode: string) => {
|
||||
async (
|
||||
authCode: string,
|
||||
state: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
) => {
|
||||
try {
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode,
|
||||
state,
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
beginAuthenticatedSession(response.token, response.user);
|
||||
if (getPendingInvitationToken()) {
|
||||
navigate('/invitations/accept', { replace: true });
|
||||
return;
|
||||
}
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: response.workspace_uuid,
|
||||
});
|
||||
if (workspaceResult.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
if (response.workspace_uuid) {
|
||||
navigate('/home', { replace: true });
|
||||
return;
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('common.spaceLoginSuccess'));
|
||||
|
||||
// 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);
|
||||
@@ -90,7 +128,15 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errorObj = err as { code?: string; msg?: string };
|
||||
if (
|
||||
errorObj.code === 'space_account_not_registered' ||
|
||||
errorObj.code === 'space_account_binding_required'
|
||||
) {
|
||||
setTerminalErrorCode(errorObj.code);
|
||||
setErrorMessage(t(`account.${errorObj.code}`));
|
||||
return;
|
||||
}
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
if (errMsg.includes('account email mismatch')) {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
@@ -113,14 +159,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) {
|
||||
@@ -128,7 +179,11 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errorObj = err as { code?: string; msg?: string };
|
||||
if (errorObj.code === 'space_account_email_mismatch') {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
return;
|
||||
}
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
if (errMsg.includes('account email mismatch')) {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
@@ -152,6 +207,8 @@ function SpaceOAuthCallbackContent() {
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
const mode = searchParams.get('mode');
|
||||
const state = searchParams.get('state');
|
||||
const workspaceUuid = searchParams.get('workspace_uuid');
|
||||
const launchAssertion = searchParams.get('launch_assertion');
|
||||
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
@@ -161,15 +218,13 @@ function SpaceOAuthCallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCode(authCode);
|
||||
|
||||
if (mode === 'bind') {
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
setCode(authCode);
|
||||
// Bind mode - verify state (token) exists
|
||||
if (!state) {
|
||||
setStatus('error');
|
||||
@@ -180,9 +235,31 @@ function SpaceOAuthCallbackContent() {
|
||||
setIsBindMode(true);
|
||||
setLocalEmail(localStorage.getItem('userEmail') || '');
|
||||
setStatus('confirm');
|
||||
} else if (workspaceUuid || launchAssertion) {
|
||||
if (!workspaceUuid || !launchAssertion) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginFailed'));
|
||||
return;
|
||||
}
|
||||
handleOAuthCallback(
|
||||
authCode ?? '',
|
||||
state ?? '',
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
);
|
||||
} else {
|
||||
// Normal login/register mode
|
||||
handleOAuthCallback(authCode);
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
setCode(authCode);
|
||||
if (!state) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginFailed'));
|
||||
return;
|
||||
}
|
||||
handleOAuthCallback(authCode, state);
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
@@ -216,9 +293,11 @@ function SpaceOAuthCallbackContent() {
|
||||
? t('account.bindSpaceSuccess')
|
||||
: t('common.spaceLoginSuccess'))}
|
||||
{status === 'error' &&
|
||||
(isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
(terminalErrorCode
|
||||
? t(`account.${terminalErrorCode}Title`)
|
||||
: isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'loading' &&
|
||||
|
||||
@@ -636,7 +636,8 @@ function AddExtensionContent() {
|
||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
selectedAsset.download_url,
|
||||
selectedAsset.id,
|
||||
selectedRelease.id,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
selectedRelease.tag_name,
|
||||
|
||||
@@ -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 canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.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>
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMonitoring && (
|
||||
<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>
|
||||
{canViewMonitoring && (
|
||||
<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>
|
||||
{canViewMonitoring && (
|
||||
<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,
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
Server,
|
||||
Puzzle,
|
||||
RefreshCcw,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -57,6 +62,9 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import WorkspaceSwitcher, {
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
} from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
@@ -380,6 +388,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 +526,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 +568,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 +817,7 @@ function NavItems({
|
||||
{itemIsPluginType && !item.debug && (
|
||||
<PluginItemMenu
|
||||
item={item}
|
||||
canManage={canManageResources}
|
||||
onUpdate={() => handlePluginUpdate(item)}
|
||||
onDelete={() => handlePluginDelete(item)}
|
||||
/>
|
||||
@@ -1063,7 +1081,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 +1348,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 +1364,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 +1406,7 @@ function PluginItemMenu({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
{isMarketplace && (
|
||||
{canManage && isMarketplace && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -1413,16 +1435,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 +1636,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');
|
||||
@@ -1655,6 +1680,19 @@ export default function HomeSidebar({
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const openWorkspaceSettings = () => openSettings('workspace');
|
||||
window.addEventListener(
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
openWorkspaceSettings,
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
openWorkspaceSettings,
|
||||
);
|
||||
});
|
||||
|
||||
function handleSettingsSectionChange(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -1678,10 +1716,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 +1854,7 @@ export default function HomeSidebar({
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clearUserInfo();
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
window.location.href = '/login';
|
||||
@@ -1880,6 +1915,10 @@ export default function HomeSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||
</div>
|
||||
|
||||
{/* Navigation items grouped by section */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
|
||||
@@ -1948,18 +1987,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>
|
||||
@@ -2048,6 +2089,15 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('workspace');
|
||||
}}
|
||||
>
|
||||
<UsersRound />
|
||||
{t('workspace.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
||||
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
@@ -83,10 +85,13 @@ 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');
|
||||
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||
const [spaceBilling, setSpaceBilling] =
|
||||
useState<WorkspaceSpaceBilling | null>(null);
|
||||
|
||||
// Expanded providers and their models
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||
@@ -140,7 +145,7 @@ export default function ModelsPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadWorkspaceBilling();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
@@ -163,16 +168,11 @@ export default function ModelsPanel({
|
||||
}
|
||||
}, [providersLoaded, providers]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
async function loadWorkspaceBilling() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
if (userInfo.account_type === 'space') {
|
||||
const creditsInfo = await httpClient.getSpaceCredits();
|
||||
setSpaceCredits(creditsInfo.credits);
|
||||
}
|
||||
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
|
||||
} catch {
|
||||
setAccountType('local');
|
||||
setSpaceBilling(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,17 +270,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,13 +536,15 @@ export default function ModelsPanel({
|
||||
<ProviderCard
|
||||
key={provider.uuid}
|
||||
provider={provider}
|
||||
canManage={canManage}
|
||||
isLangBotModels={isLangBotModels}
|
||||
supportTypes={requesterSupportTypes[provider.requester]}
|
||||
isExpanded={expandedProviders.has(provider.uuid)}
|
||||
isLoading={loadingProviders.has(provider.uuid)}
|
||||
models={providerModels[provider.uuid]}
|
||||
accountType={accountType}
|
||||
spaceCredits={spaceCredits}
|
||||
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
||||
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
||||
spaceCredits={spaceBilling?.credits ?? null}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
@@ -628,10 +622,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,12 +38,14 @@ import AddModelPopover from './AddModelPopover';
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ModelProvider;
|
||||
canManage: boolean;
|
||||
isLangBotModels?: boolean;
|
||||
supportTypes?: string[];
|
||||
isExpanded: boolean;
|
||||
isLoading: boolean;
|
||||
models?: ProviderModels;
|
||||
accountType: 'local' | 'space';
|
||||
isWorkspaceOwner: boolean;
|
||||
ownerSpaceBound: boolean;
|
||||
spaceCredits: number | null;
|
||||
// Popover states
|
||||
addModelPopoverOpen: string | null;
|
||||
@@ -101,12 +103,14 @@ function maskApiKey(key: string): string {
|
||||
|
||||
export default function ProviderCard({
|
||||
provider,
|
||||
canManage,
|
||||
isLangBotModels = false,
|
||||
supportTypes,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
models,
|
||||
accountType,
|
||||
isWorkspaceOwner,
|
||||
ownerSpaceBound,
|
||||
spaceCredits,
|
||||
addModelPopoverOpen,
|
||||
editModelPopoverOpen,
|
||||
@@ -196,7 +200,7 @@ export default function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||
{isLangBotModels && accountType !== 'space' && (
|
||||
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -206,33 +210,41 @@ export default function ProviderCard({
|
||||
}}
|
||||
>
|
||||
<LogIn className="h-4 w-4 mr-1" />
|
||||
{t('models.loginWithSpace')}
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</Button>
|
||||
)}
|
||||
{isLangBotModels &&
|
||||
accountType === 'space' &&
|
||||
spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.usesOwnerSpaceBilling')}
|
||||
</span>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</span>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -317,7 +329,7 @@ export default function ProviderCard({
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
{canManage && !isLangBotModels && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AddModelPopover
|
||||
isOpen={
|
||||
@@ -404,6 +416,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="llm"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -441,6 +454,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="embedding"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -472,6 +486,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,413 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
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, systemInfo } from '@/app/infra/http';
|
||||
import {
|
||||
PanelBody,
|
||||
PanelToolbar,
|
||||
} from '@/app/home/components/settings-dialog/panel-layout';
|
||||
|
||||
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 = permissions.has('member.invite');
|
||||
const canUpdateMembers = permissions.has('member.update_role');
|
||||
const canRemoveMembers = permissions.has('member.remove');
|
||||
const canTransferOwner = permissions.has('owner.transfer');
|
||||
const cloudPortalURL = workspaceInfo
|
||||
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
||||
: '';
|
||||
|
||||
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.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,
|
||||
);
|
||||
setOneTimeInviteLink(response.link);
|
||||
setInviteEmail('');
|
||||
await loadWorkspace();
|
||||
toast.success(t(`workspace.delivery.${response.delivery.status}`));
|
||||
} 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>
|
||||
{workspaceInfo && (
|
||||
<Badge variant="secondary">
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isCloudProjection && workspaceInfo && (
|
||||
<Button asChild size="sm">
|
||||
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
||||
{t('workspace.upgradePlan')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.members')}
|
||||
</h3>
|
||||
</div>
|
||||
<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,96 @@
|
||||
import { Building2, Check, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
switchWorkspaceAndReload,
|
||||
useCurrentWorkspace,
|
||||
useWorkspaceBootstrap,
|
||||
} from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings';
|
||||
|
||||
export function requestWorkspaceSettings(): void {
|
||||
window.dispatchEvent(new Event(OPEN_WORKSPACE_SETTINGS_EVENT));
|
||||
}
|
||||
|
||||
export default function WorkspaceSwitcher({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaces = useWorkspaceBootstrap();
|
||||
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-9 min-w-0 justify-start px-2.5 text-sm', className)}
|
||||
aria-label={t('workspace.switchWorkspace')}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="truncate">{currentWorkspace.workspace.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 p-1.5">
|
||||
<DropdownMenuLabel className="px-3 py-2 text-sm">
|
||||
{t('workspace.switchWorkspace')}
|
||||
</DropdownMenuLabel>
|
||||
{workspaces.map((entry) => {
|
||||
const selected =
|
||||
entry.workspace.uuid === currentWorkspace.workspace.uuid;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.workspace.uuid}
|
||||
className="min-h-11 gap-2 px-2 py-1.5"
|
||||
onClick={() => {
|
||||
if (!selected)
|
||||
void switchWorkspaceAndReload(entry.workspace.uuid);
|
||||
}}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="max-w-[7rem] min-w-0 flex-1 truncate font-medium">
|
||||
{entry.workspace.name}
|
||||
</span>
|
||||
{entry.workspace.source === 'cloud_projection' && (
|
||||
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{entry.plan_name || t('workspace.planUnavailable')}
|
||||
</span>
|
||||
)}
|
||||
{selected && <Check className="size-4" />}
|
||||
{selected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t('workspace.settings')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
requestWorkspaceSettings();
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+66
-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';
|
||||
@@ -82,8 +82,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 +90,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
|
||||
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
@@ -566,11 +567,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
|
||||
// stdio mode requires the Box sandbox at runtime. If the user picks
|
||||
// stdio while Box is disabled / unreachable, the server would refuse
|
||||
// to start anyway — block creation upfront so they aren't surprised
|
||||
// by an immediate "Connection failed" on the detail page.
|
||||
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
|
||||
const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled;
|
||||
const stdioBlockedByBox =
|
||||
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
|
||||
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
@@ -578,8 +583,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveBlockedChange?.(stdioBlockedByBox);
|
||||
}, [stdioBlockedByBox, onSaveBlockedChange]);
|
||||
onSaveBlockedChange?.(stdioBlocked);
|
||||
}, [stdioBlocked, onSaveBlockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onTestingChange?.(mcpTesting);
|
||||
@@ -595,10 +600,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
testMcp: () => testMcp(),
|
||||
isTesting: mcpTesting,
|
||||
}),
|
||||
// testMcp now reads everything via form.getValues(), so it does not need
|
||||
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
|
||||
// exposed isTesting flag stays accurate.
|
||||
[mcpTesting],
|
||||
// Form values are read through form.getValues(); policy and runtime health
|
||||
// remain closure values and must refresh the imperative handler.
|
||||
[mcpTesting, mcpStdioEnabled, boxAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -760,6 +764,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// Belt-and-suspenders: even though the Save button is disabled when
|
||||
// stdio is unselectable, intercept programmatic submits too.
|
||||
if (value.mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
return;
|
||||
}
|
||||
if (value.mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
return;
|
||||
@@ -829,6 +837,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
// Read every field via form.getValues() rather than the captured
|
||||
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
|
||||
// imperative handle (formRef.current.testMcp()) whose closure is only
|
||||
@@ -1037,13 +1055,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
<SelectItem
|
||||
value="stdio"
|
||||
disabled={!mcpStdioEnabled || !boxAvailable}
|
||||
>
|
||||
{t('mcp.local')}
|
||||
{!boxAvailable && (
|
||||
{!mcpStdioEnabled ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.disabledByPolicy')})
|
||||
</span>
|
||||
) : !boxAvailable ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1052,6 +1077,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
? t('mcp.localModeDescription')
|
||||
: t('mcp.remoteModeDescription')}
|
||||
</FormDescription>
|
||||
{stdioBlockedByPolicy && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t('mcp.stdioDisabledByPolicy')}
|
||||
</div>
|
||||
)}
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice
|
||||
hint={boxHint}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SystemStatusCard({
|
||||
try {
|
||||
const [plugin, box] = await Promise.all([
|
||||
httpClient.getPluginSystemStatus().catch(() => null),
|
||||
httpClient.getBoxStatus().catch(() => null),
|
||||
httpClient.getBoxRuntimeStatus().catch(() => null),
|
||||
]);
|
||||
const sessions = box?.hidden
|
||||
? []
|
||||
|
||||
@@ -23,9 +23,13 @@ import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { buildConversationTurns } from './utils/conversationTurns';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
function MonitoringPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canExport =
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
@@ -154,7 +158,7 @@ function MonitoringPageContent() {
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportDropdown filterState={filterState} />
|
||||
{canExport && <ExportDropdown filterState={filterState} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -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 canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.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>
|
||||
)}
|
||||
{canViewMonitoring && (
|
||||
<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>
|
||||
{canViewMonitoring && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -346,10 +346,16 @@ export interface ApiRespSystemInfo {
|
||||
debug: boolean;
|
||||
version: string;
|
||||
edition: string;
|
||||
/** Independent instance-level gate for local stdio MCP transports. */
|
||||
mcp_stdio_enabled: boolean;
|
||||
cloud_service_url: string;
|
||||
enable_marketplace: boolean;
|
||||
allow_modify_login_info: boolean;
|
||||
disable_models_service: boolean;
|
||||
invitation_delivery?: {
|
||||
enabled: boolean;
|
||||
provider: 'resend' | 'smtp' | null;
|
||||
};
|
||||
limitation: SystemLimitation;
|
||||
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
|
||||
* config.yaml). Shown on adapter config forms whose platform requires
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
/** Signed Cloud display metadata; never used for client-side authorization. */
|
||||
plan_name?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceSpaceBilling {
|
||||
credits: number | null;
|
||||
owner_space_bound: boolean;
|
||||
is_workspace_owner: boolean;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export type WorkspaceInvitationDeliveryStatus = 'sent' | 'link_only' | 'failed';
|
||||
|
||||
export interface WorkspaceInvitationDelivery {
|
||||
status: WorkspaceInvitationDeliveryStatus;
|
||||
provider: 'resend' | 'smtp' | null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
/**
|
||||
* Load the instance-level stdio MCP gate independently of Box health.
|
||||
*
|
||||
* The hook fails closed while loading or when System Info is unavailable.
|
||||
* This is only a WebUI guard; the backend loader enforces the same gate at
|
||||
* the final transport boundary.
|
||||
*/
|
||||
export function useMCPStdioPolicy() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const info = await httpClient.getSystemInfo();
|
||||
setEnabled(info.mcp_stdio_enabled === true);
|
||||
} catch {
|
||||
setEnabled(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { enabled, loading, refresh };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseHttpClient } from './BaseHttpClient';
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import {
|
||||
ApiRespProviderRequesters,
|
||||
ApiRespProviderRequester,
|
||||
@@ -61,6 +61,16 @@ 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,
|
||||
WorkspaceInvitationDelivery,
|
||||
WorkspaceMembership,
|
||||
WorkspaceBootstrapResponse,
|
||||
WorkspaceRole,
|
||||
WorkspaceSpaceBilling,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
|
||||
/**
|
||||
* 后端服务客户端
|
||||
@@ -733,13 +743,15 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
public installPluginFromGithub(
|
||||
assetUrl: string,
|
||||
assetId: number,
|
||||
releaseId: number,
|
||||
owner: string,
|
||||
repo: string,
|
||||
releaseTag: string,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/plugins/install/github', {
|
||||
asset_url: assetUrl,
|
||||
asset_id: assetId,
|
||||
release_id: releaseId,
|
||||
owner,
|
||||
repo,
|
||||
release_tag: releaseTag,
|
||||
@@ -1057,6 +1069,10 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get('/api/v1/plugins/debug-info');
|
||||
}
|
||||
|
||||
public getBoxRuntimeStatus(): Promise<ApiRespBoxStatus> {
|
||||
return this.get('/api/v1/box/runtime-status');
|
||||
}
|
||||
|
||||
public getBoxStatus(): Promise<ApiRespBoxStatus> {
|
||||
return this.get('/api/v1/box/status');
|
||||
}
|
||||
@@ -1067,19 +1083,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 +1113,168 @@ 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 }> {
|
||||
public getWorkspaceSpaceBilling(): Promise<WorkspaceSpaceBilling> {
|
||||
return this.get('/api/v1/user/space-credits');
|
||||
}
|
||||
|
||||
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', undefined, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
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;
|
||||
link: string;
|
||||
delivery: WorkspaceInvitationDelivery;
|
||||
}> {
|
||||
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 +1285,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 +1300,46 @@ 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,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
user: string;
|
||||
workspace_uuid?: 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,
|
||||
workspace_uuid: workspaceUuid,
|
||||
launch_assertion: launchAssertion,
|
||||
},
|
||||
{ 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,16 +1,39 @@
|
||||
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 = {
|
||||
debug: false,
|
||||
version: '',
|
||||
edition: 'community',
|
||||
mcp_stdio_enabled: false,
|
||||
enable_marketplace: true,
|
||||
cloud_service_url: '',
|
||||
allow_modify_login_info: true,
|
||||
disable_models_service: false,
|
||||
invitation_delivery: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
},
|
||||
limitation: {
|
||||
max_bots: -1,
|
||||
max_pipelines: -1,
|
||||
@@ -23,6 +46,7 @@ export const systemInfo: ApiRespSystemInfo = {
|
||||
|
||||
// 用户信息
|
||||
export let userInfo: {
|
||||
account_uuid: string;
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
has_password: boolean;
|
||||
@@ -109,25 +133,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,404 @@
|
||||
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,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
clearUserInfo,
|
||||
getPendingInvitationToken,
|
||||
setPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
|
||||
type InvitationView = {
|
||||
invitation: WorkspaceInvitation;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
function invitationErrorKey(
|
||||
code: string | null | undefined,
|
||||
fallback: 'workspace.invitationInvalid' | 'workspace.invitationAcceptFailed',
|
||||
) {
|
||||
switch (code) {
|
||||
case 'invitation_invalid':
|
||||
return 'workspace.invitationInvalid';
|
||||
case 'invitation_expired':
|
||||
return 'workspace.invitationExpired';
|
||||
case 'invitation_revoked':
|
||||
return 'workspace.invitationAlreadyRevoked';
|
||||
case 'invitation_used':
|
||||
return 'workspace.invitationAlreadyUsed';
|
||||
case 'invitation_email_mismatch':
|
||||
return 'workspace.invitationEmailMismatch';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
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('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
useState(false);
|
||||
|
||||
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();
|
||||
const deferredErrorCode = new URLSearchParams(window.location.search).get(
|
||||
'error',
|
||||
);
|
||||
setToken(invitationToken);
|
||||
backendClient
|
||||
.getAccountInfo()
|
||||
.then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
})
|
||||
.catch(() => setPasswordRegistrationEnabled(false));
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
backendClient
|
||||
.inspectWorkspaceInvitation(invitationToken)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setView(response);
|
||||
if (deferredErrorCode) {
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(
|
||||
deferredErrorCode,
|
||||
'workspace.invitationAcceptFailed',
|
||||
),
|
||||
),
|
||||
);
|
||||
setStatus('error');
|
||||
} else {
|
||||
setStatus('ready');
|
||||
}
|
||||
})
|
||||
.catch((error: { code?: string; msg?: string }) => {
|
||||
if (cancelled) return;
|
||||
clearPendingInvitationToken();
|
||||
setErrorMessage(
|
||||
t(invitationErrorKey(error.code, 'workspace.invitationInvalid')),
|
||||
);
|
||||
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,
|
||||
);
|
||||
if (registration) {
|
||||
clearPendingInvitationToken();
|
||||
toast.success(t('workspace.invitationAccepted'));
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(apiError.code, 'workspace.invitationAcceptFailed'),
|
||||
),
|
||||
);
|
||||
setStatus(
|
||||
apiError.code && TERMINAL_INVITATION_ERROR_CODES.has(apiError.code)
|
||||
? 'error'
|
||||
: 'ready',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 logoutAndReturn() {
|
||||
if (token) setPendingInvitationToken(token);
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t('workspace.authenticatedInvitationNotice')}
|
||||
</div>
|
||||
<Button className="w-full" onClick={logoutAndReturn}>
|
||||
{t('workspace.logoutAndReturn')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/login?invitation=1&auto=space')}
|
||||
>
|
||||
{t('common.loginWithSpace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+92
-24
@@ -19,8 +19,14 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient, initializeUserInfo } from '@/app/infra/http';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
beginAuthenticatedSession,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
getPendingInvitationToken,
|
||||
httpClient,
|
||||
} from '@/app/infra/http';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Mail,
|
||||
@@ -43,17 +49,24 @@ const formSchema = (t: (key: string) => string) =>
|
||||
password: z.string().min(1, t('common.emptyPassword')),
|
||||
});
|
||||
|
||||
type AccountType = 'local' | 'space';
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
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);
|
||||
const autoSpaceLoginStarted = useRef(false);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -75,8 +88,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 +122,64 @@ 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,
|
||||
): Promise<boolean> {
|
||||
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 (error) {
|
||||
const apiError = error as { code?: string };
|
||||
const errorCode =
|
||||
typeof apiError.code === 'string'
|
||||
? apiError.code
|
||||
: 'invitation_accept_failed';
|
||||
const invitationPath = TERMINAL_INVITATION_ERROR_CODES.has(errorCode)
|
||||
? `/invitations/accept?error=${encodeURIComponent(errorCode)}`
|
||||
: '/invitations/accept';
|
||||
navigate(invitationPath, { replace: true });
|
||||
toast.error(
|
||||
t(
|
||||
errorCode === 'invitation_email_mismatch'
|
||||
? 'workspace.invitationEmailMismatch'
|
||||
: 'workspace.invitationAcceptFailed',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid,
|
||||
});
|
||||
if (result.status === 'selection-required') {
|
||||
navigate('/workspaces/select?returnTo=%2Fhome', { replace: true });
|
||||
return true;
|
||||
}
|
||||
if (result.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
navigate('/home');
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
handleLogin(values.email, values.password);
|
||||
}
|
||||
@@ -126,18 +188,16 @@ export default function Login() {
|
||||
httpClient
|
||||
.authUser(username, password)
|
||||
.then(async (res) => {
|
||||
localStorage.setItem('token', res.token);
|
||||
localStorage.setItem('userEmail', username);
|
||||
await initializeUserInfo();
|
||||
navigate('/home');
|
||||
toast.success(t('common.loginSuccess'));
|
||||
if (await finishLogin(res.token, username)) {
|
||||
toast.success(t('common.loginSuccess'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.loginFailed'));
|
||||
});
|
||||
}
|
||||
|
||||
const handleSpaceLoginClick = async () => {
|
||||
const handleSpaceLoginClick = useCallback(async () => {
|
||||
setSpaceLoading(true);
|
||||
try {
|
||||
const currentOrigin = window.location.origin;
|
||||
@@ -148,7 +208,20 @@ export default function Login() {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
loading ||
|
||||
!showSpaceLogin ||
|
||||
autoSpaceLoginStarted.current ||
|
||||
new URLSearchParams(window.location.search).get('auto') !== 'space'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
autoSpaceLoginStarted.current = true;
|
||||
void handleSpaceLoginClick();
|
||||
}, [handleSpaceLoginClick, loading, showSpaceLogin]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -211,11 +284,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 +305,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 +338,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
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
useState(true);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -55,6 +57,12 @@ export default function Register() {
|
||||
|
||||
useEffect(() => {
|
||||
getIsInitialized();
|
||||
httpClient
|
||||
.getAccountInfo()
|
||||
.then((info) =>
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false),
|
||||
)
|
||||
.catch(() => setPasswordRegistrationEnabled(true));
|
||||
}, []);
|
||||
|
||||
function getIsInitialized() {
|
||||
@@ -159,72 +167,79 @@ export default function Register() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{passwordRegistrationEnabled && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Account Registration */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Local Account Registration */}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('common.agreementNotice')}{' '}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const enUS = {
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
spaceLoginRecommended:
|
||||
'Recommended: Use official stable model APIs and cloud services',
|
||||
loginLocal: 'Login with local account',
|
||||
@@ -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',
|
||||
@@ -276,8 +277,12 @@ const enUS = {
|
||||
searchProviders: 'Search providers...',
|
||||
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
||||
credits: 'Credits',
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
loginToUseModels: 'Login with Space to use cloud models',
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
noModels: 'No models configured',
|
||||
langbotModels: 'LangBot Models',
|
||||
spaceTrialTooltip:
|
||||
@@ -826,6 +831,9 @@ const enUS = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
|
||||
boxRequired: 'requires Box',
|
||||
disabledByPolicy: 'disabled by policy',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP is disabled for this deployment. Use a remote MCP server instead.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
|
||||
toolsFound: 'tools',
|
||||
@@ -1279,7 +1287,96 @@ const enUS = {
|
||||
'Invalid bind request. Please try again from account settings.',
|
||||
setPasswordHint: 'Set a password to login with email and password',
|
||||
spaceEmailMismatch:
|
||||
'Space login email does not match the local account email',
|
||||
'The Space login email does not match the local account email.',
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
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',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
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',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
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: 'This invitation link is missing required information.',
|
||||
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',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch 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',
|
||||
@@ -1532,6 +1629,7 @@ const enUS = {
|
||||
settingsDialog: {
|
||||
title: 'Settings',
|
||||
nav: {
|
||||
workspace: 'Workspace',
|
||||
models: 'Models',
|
||||
api: 'API',
|
||||
storage: 'Storage',
|
||||
|
||||
@@ -83,7 +83,7 @@ const esES = {
|
||||
loading: 'Cargando...',
|
||||
fieldRequired: 'Este campo es obligatorio',
|
||||
or: 'o',
|
||||
loginWithSpace: 'Iniciar sesión con Space',
|
||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||
loginLocal: 'Iniciar sesión con cuenta local',
|
||||
@@ -286,7 +286,7 @@ const esES = {
|
||||
searchProviders: 'Buscar proveedores...',
|
||||
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
|
||||
credits: 'Créditos',
|
||||
loginWithSpace: 'Iniciar sesión con Space',
|
||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube',
|
||||
noModels: 'No hay modelos configurados',
|
||||
langbotModels: 'Modelos LangBot',
|
||||
@@ -840,6 +840,9 @@ const esES = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.',
|
||||
boxRequired: 'requiere Box',
|
||||
disabledByPolicy: 'desactivado por la política',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP está deshabilitado en este despliegue. Use un servidor MCP remoto.',
|
||||
stdioBlockedByBoxToast:
|
||||
'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione modo http/sse.',
|
||||
toolsFound: 'herramientas',
|
||||
|
||||
@@ -81,7 +81,7 @@ const jaJP = {
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
spaceLoginRecommended:
|
||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||
loginLocal: 'ローカルアカウントでログイン',
|
||||
@@ -169,6 +169,7 @@ const jaJP = {
|
||||
actions: 'アクション',
|
||||
apiKeyCreatedMessage:
|
||||
'この API キーをコピーしてください。もしボタンが無効な場合は手動でコピーしてください。',
|
||||
apiKeyStoredSecurely: 'シークレットは作成時のみ表示されます',
|
||||
none: 'なし',
|
||||
more: 'もっと見る ({{count}})',
|
||||
less: '折りたたむ',
|
||||
@@ -281,8 +282,12 @@ const jaJP = {
|
||||
searchProviders: 'プロバイダーを検索...',
|
||||
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
||||
credits: 'クレジット',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
||||
ownerMustBindSpace:
|
||||
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
|
||||
usesOwnerSpaceBilling:
|
||||
'ワークスペース所有者の Space 課金とクレジットを使用します。',
|
||||
noModels: 'モデルがありません',
|
||||
langbotModels: 'LangBot モデル',
|
||||
spaceTrialTooltip:
|
||||
@@ -832,6 +837,9 @@ const jaJP = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
|
||||
boxRequired: 'Box が必要',
|
||||
disabledByPolicy: 'ポリシーにより無効',
|
||||
stdioDisabledByPolicy:
|
||||
'このデプロイでは Stdio MCP が無効です。リモート MCP サーバーを使用してください。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
|
||||
toolsFound: '個のツール',
|
||||
@@ -1285,6 +1293,91 @@ const jaJP = {
|
||||
'パスワードを設定するとメールとパスワードでログインできます',
|
||||
spaceEmailMismatch:
|
||||
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
|
||||
space_account_not_registeredTitle: 'アカウントが登録されていません',
|
||||
space_account_not_registered:
|
||||
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
|
||||
space_account_binding_requiredTitle: 'Space の連携が必要です',
|
||||
space_account_binding_required:
|
||||
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
|
||||
},
|
||||
workspace: {
|
||||
title: 'ワークスペース',
|
||||
description: 'メンバー、ロール、招待リンクを管理します',
|
||||
selectTitle: 'ワークスペースを選択',
|
||||
selectDescription: 'LangBot で使用するワークスペースを選択してください。',
|
||||
selectionLoadFailed:
|
||||
'ワークスペースを読み込めませんでした。もう一度お試しください。',
|
||||
switchWorkspace: 'ワークスペースを切り替え',
|
||||
ossSingletonDescription:
|
||||
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
|
||||
cloudManagedDescription:
|
||||
'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。',
|
||||
loadFailed: 'ワークスペース情報の読み込みに失敗しました',
|
||||
members: 'メンバー',
|
||||
you: 'あなた',
|
||||
inviteMember: 'メンバーを招待',
|
||||
inviteDescription:
|
||||
'現在のワークスペースにユーザーを追加する一度限りのリンクを作成します。',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: '招待を作成',
|
||||
invitationCreated: '招待を作成しました',
|
||||
delivery: {
|
||||
sent: '招待メールを送信しました',
|
||||
link_only: '招待リンクを作成しました',
|
||||
failed: '招待リンクを作成しましたが、メールを送信できませんでした',
|
||||
},
|
||||
invitationCreateFailed: '招待の作成に失敗しました',
|
||||
oneTimeLinkWarning:
|
||||
'このリンクを今すぐコピーしてください。一度だけ表示されます。',
|
||||
copyInvitation: '招待リンクをコピー',
|
||||
invitationCopied: '招待リンクをコピーしました',
|
||||
pendingInvitations: '保留中の招待',
|
||||
expiresAt: '{{date}} に期限切れ',
|
||||
revokeInvitation: '招待を取り消す',
|
||||
invitationRevoked: '招待を取り消しました',
|
||||
invitationRevokeFailed: '招待の取り消しに失敗しました',
|
||||
acceptInvitation: '招待を承認',
|
||||
invitedToWorkspace: '{{workspace}} に招待されました',
|
||||
checkingInvitation: '招待を確認しています...',
|
||||
invitationMissing: 'この招待リンクには必要な情報がありません。',
|
||||
invitationExpired: 'この招待は期限切れです。',
|
||||
invitationAlreadyRevoked: 'この招待は取り消されました。',
|
||||
invitationAlreadyUsed: 'この招待はすでに使用されています。',
|
||||
invitationInvalid: 'この招待は無効か、利用できなくなっています。',
|
||||
invitationAccepted: '招待を承認しました',
|
||||
invitationAcceptFailed: '招待の承認に失敗しました',
|
||||
invitationEmailMismatch: 'この招待は別のメールアドレスに送られたものです。',
|
||||
existingAccountLoginRequired:
|
||||
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
|
||||
acceptAsCurrentAccount: '現在のアカウントで承認',
|
||||
authenticatedInvitationNotice:
|
||||
'一度ログアウトし、招待されたアカウントでログインしてください。招待は保持されます。',
|
||||
logoutAndReturn: 'ログアウトしてこの招待に戻る',
|
||||
switchAccount: 'アカウントを切り替える',
|
||||
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: 'ダッシュボード',
|
||||
@@ -1538,6 +1631,7 @@ const jaJP = {
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
nav: {
|
||||
workspace: 'ワークスペース',
|
||||
models: 'モデル',
|
||||
api: 'API',
|
||||
storage: 'ストレージ',
|
||||
|
||||
@@ -80,7 +80,7 @@ const ruRU = {
|
||||
loading: 'Загрузка...',
|
||||
fieldRequired: 'Это поле обязательно для заполнения',
|
||||
or: 'или',
|
||||
loginWithSpace: 'Войти через Space',
|
||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||
loginLocal: 'Войти с локальной учётной записью',
|
||||
@@ -284,7 +284,7 @@ const ruRU = {
|
||||
searchProviders: 'Поиск провайдеров...',
|
||||
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
|
||||
credits: 'Кредиты',
|
||||
loginWithSpace: 'Войти через Space',
|
||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели',
|
||||
noModels: 'Модели не настроены',
|
||||
langbotModels: 'Модели LangBot',
|
||||
@@ -837,6 +837,9 @@ const ruRU = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
|
||||
boxRequired: 'требуется Box',
|
||||
disabledByPolicy: 'отключено политикой',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP отключён в этом развёртывании. Используйте удалённый MCP-сервер.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
|
||||
toolsFound: 'инструментов',
|
||||
|
||||
@@ -80,7 +80,7 @@ const thTH = {
|
||||
loading: 'กำลังโหลด...',
|
||||
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
|
||||
or: 'หรือ',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
spaceLoginRecommended:
|
||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
||||
@@ -273,7 +273,7 @@ const thTH = {
|
||||
searchProviders: 'ค้นหาผู้ให้บริการ...',
|
||||
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
|
||||
credits: 'เครดิต',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
|
||||
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
||||
langbotModels: 'โมเดล LangBot',
|
||||
@@ -815,6 +815,9 @@ const thTH = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
|
||||
boxRequired: 'ต้องใช้ Box',
|
||||
disabledByPolicy: 'ถูกปิดใช้งานโดยนโยบาย',
|
||||
stdioDisabledByPolicy:
|
||||
'การติดตั้งใช้งานนี้ปิด Stdio MCP อยู่ โปรดใช้เซิร์ฟเวอร์ MCP แบบระยะไกล',
|
||||
stdioBlockedByBoxToast:
|
||||
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
|
||||
toolsFound: 'เครื่องมือ',
|
||||
|
||||
@@ -81,7 +81,7 @@ const viVN = {
|
||||
loading: 'Đang tải...',
|
||||
fieldRequired: 'Trường này là bắt buộc',
|
||||
or: 'hoặc',
|
||||
loginWithSpace: 'Đăng nhập với Space',
|
||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
||||
@@ -281,7 +281,7 @@ const viVN = {
|
||||
searchProviders: 'Tìm kiếm nhà cung cấp...',
|
||||
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
|
||||
credits: 'Tín dụng',
|
||||
loginWithSpace: 'Đăng nhập với Space',
|
||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||
loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây',
|
||||
noModels: 'Chưa cấu hình mô hình nào',
|
||||
langbotModels: 'Mô hình LangBot',
|
||||
@@ -830,6 +830,9 @@ const viVN = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.',
|
||||
boxRequired: 'cần Box',
|
||||
disabledByPolicy: 'bị tắt theo chính sách',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP đã bị tắt trong bản triển khai này. Hãy dùng máy chủ MCP từ xa.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn chế độ http/sse.',
|
||||
toolsFound: 'công cụ',
|
||||
|
||||
@@ -79,7 +79,7 @@ const zhHans = {
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||
loginLocal: '使用本地账号登录',
|
||||
loginWithPassword: '通过密码登录',
|
||||
@@ -156,6 +156,7 @@ const zhHans = {
|
||||
webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统',
|
||||
actions: '操作',
|
||||
apiKeyCreatedMessage: '请复制此 API 密钥,若按钮无效,请手动复制。',
|
||||
apiKeyStoredSecurely: '密钥仅在创建时显示',
|
||||
none: '无',
|
||||
more: '更多 ({{count}})',
|
||||
less: '收起',
|
||||
@@ -264,8 +265,10 @@ const zhHans = {
|
||||
searchProviders: '搜索供应商...',
|
||||
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
||||
credits: '积分',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
||||
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。',
|
||||
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。',
|
||||
noModels: '暂无模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
@@ -792,6 +795,8 @@ const zhHans = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
|
||||
boxRequired: '需要 Box',
|
||||
disabledByPolicy: '已被策略禁用',
|
||||
stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
|
||||
toolsFound: '个工具',
|
||||
@@ -1216,6 +1221,91 @@ const zhHans = {
|
||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
||||
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
|
||||
space_account_not_registeredTitle: '账户尚未注册',
|
||||
space_account_not_registered:
|
||||
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。',
|
||||
space_account_binding_requiredTitle: '需要绑定 Space',
|
||||
space_account_binding_required:
|
||||
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。',
|
||||
},
|
||||
workspace: {
|
||||
title: '工作区',
|
||||
description: '管理成员、角色与邀请链接',
|
||||
selectTitle: '选择工作区',
|
||||
selectDescription: '选择你要进入的 LangBot 工作区。',
|
||||
selectionLoadFailed: '无法加载你的工作区,请重试。',
|
||||
switchWorkspace: '切换工作区',
|
||||
settings: '工作区设置',
|
||||
currentPlan: '当前计划',
|
||||
planUnavailable: '暂不可用',
|
||||
upgradePlan: '切换或升级计划',
|
||||
ossSingletonDescription:
|
||||
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
||||
cloudManagedDescription:
|
||||
'此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。',
|
||||
loadFailed: '加载工作区信息失败',
|
||||
members: '成员',
|
||||
you: '你',
|
||||
inviteMember: '邀请成员',
|
||||
inviteDescription: '创建一次性链接,将其他用户加入当前工作区。',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: '创建邀请',
|
||||
invitationCreated: '邀请已创建',
|
||||
delivery: {
|
||||
sent: '邀请邮件已发送',
|
||||
link_only: '邀请链接已创建',
|
||||
failed: '邀请链接已创建,但邮件发送失败',
|
||||
},
|
||||
invitationCreateFailed: '创建邀请失败',
|
||||
oneTimeLinkWarning: '请立即复制此链接。它只显示一次。',
|
||||
copyInvitation: '复制邀请链接',
|
||||
invitationCopied: '邀请链接已复制',
|
||||
pendingInvitations: '待接受邀请',
|
||||
expiresAt: '{{date}} 过期',
|
||||
revokeInvitation: '撤销邀请',
|
||||
invitationRevoked: '邀请已撤销',
|
||||
invitationRevokeFailed: '撤销邀请失败',
|
||||
acceptInvitation: '接受邀请',
|
||||
invitedToWorkspace: '你已受邀加入 {{workspace}}',
|
||||
checkingInvitation: '正在验证邀请…',
|
||||
invitationMissing: '此邀请链接缺少必要信息。',
|
||||
invitationExpired: '此邀请已过期。',
|
||||
invitationAlreadyRevoked: '此邀请已被撤销。',
|
||||
invitationAlreadyUsed: '此邀请已被使用。',
|
||||
invitationInvalid: '此邀请无效或已不可用。',
|
||||
invitationAccepted: '已接受邀请',
|
||||
invitationAcceptFailed: '接受邀请失败',
|
||||
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
|
||||
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
|
||||
acceptAsCurrentAccount: '使用当前账户接受',
|
||||
authenticatedInvitationNotice:
|
||||
'请先退出,再使用受邀账户登录。邀请令牌会被保留。',
|
||||
logoutAndReturn: '退出并返回此邀请',
|
||||
switchAccount: '切换账号',
|
||||
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: '仪表盘',
|
||||
@@ -1467,6 +1557,7 @@ const zhHans = {
|
||||
settingsDialog: {
|
||||
title: '设置',
|
||||
nav: {
|
||||
workspace: '工作区',
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '存储',
|
||||
|
||||
@@ -79,7 +79,7 @@ const zhHant = {
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
loginWithSpace: '透過 Space 登入',
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||
loginLocal: '使用本地帳號登入',
|
||||
loginWithPassword: '透過密碼登入',
|
||||
@@ -263,7 +263,7 @@ const zhHant = {
|
||||
searchProviders: '搜尋供應商...',
|
||||
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
||||
credits: '積分',
|
||||
loginWithSpace: '使用 Space 登入',
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
||||
noModels: '暫無模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
@@ -791,6 +791,8 @@ const zhHant = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
|
||||
boxRequired: '需要 Box',
|
||||
disabledByPolicy: '已被策略停用',
|
||||
stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
|
||||
toolsFound: '個工具',
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user