feat(cloud): complete Workspace settings navigation

This commit is contained in:
dadachann
2026-07-25 13:51:37 +08:00
parent 64e772e32d
commit d3f08a90b1
11 changed files with 222 additions and 36 deletions
@@ -36,6 +36,7 @@ import {
Server,
Puzzle,
RefreshCcw,
UsersRound,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -61,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,
@@ -1676,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());
@@ -1953,6 +1970,11 @@ export default function HomeSidebar({
{/* Footer */}
<SidebarFooter>
{currentWorkspace?.workspace.source === 'cloud_projection' && (
<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>
)}
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
@@ -2068,6 +2090,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);
@@ -1,20 +1,35 @@
import { Building2 } from 'lucide-react';
import {
Building2,
Check,
ExternalLink,
Settings,
Sparkles,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
switchWorkspaceAndReload,
systemInfo,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
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,
}: {
@@ -23,34 +38,78 @@ export default function WorkspaceSwitcher({
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
const isCloud = currentWorkspace?.workspace.source === 'cloud_projection';
if (!currentWorkspace || workspaces.length <= 1) return null;
if (!currentWorkspace || (!isCloud && workspaces.length <= 1)) return null;
const cloudPortalUrl = `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(currentWorkspace.workspace.uuid)}`;
return (
<Select
value={currentWorkspace.workspace.uuid}
onValueChange={switchWorkspaceAndReload}
>
<SelectTrigger
size="sm"
className={cn('min-w-44 max-w-64', className)}
aria-label={t('workspace.switchWorkspace')}
>
<Building2 className="size-4 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{workspaces.map((entry) => (
<SelectItem key={entry.workspace.uuid} value={entry.workspace.uuid}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{entry.workspace.name}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('min-w-44 max-w-64 justify-start', className)}
aria-label={t('workspace.switchWorkspace')}
>
<Building2 className="size-4 shrink-0" />
<span className="truncate">{currentWorkspace.workspace.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-64">
<DropdownMenuLabel>{t('workspace.switchWorkspace')}</DropdownMenuLabel>
{workspaces.map((entry) => {
const selected =
entry.workspace.uuid === currentWorkspace.workspace.uuid;
return (
<DropdownMenuItem
key={entry.workspace.uuid}
onClick={() => {
if (!selected)
void switchWorkspaceAndReload(entry.workspace.uuid);
}}
>
<Building2 className="size-4" />
<span className="min-w-0 flex-1 truncate">
{entry.workspace.name}
</span>
<span className="text-xs text-muted-foreground">
{t(`workspace.roles.${entry.membership.role}`)}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selected && <Check className="size-4" />}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{isCloud && (
<>
<DropdownMenuLabel className="flex items-center justify-between gap-3 font-normal">
<span className="text-muted-foreground">
{t('workspace.currentPlan')}
</span>
<span className="font-medium text-foreground">
{currentWorkspace.plan_name || t('workspace.planUnavailable')}
</span>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<a
href={cloudPortalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Sparkles className="size-4" />
{t('workspace.upgradePlan')}
<ExternalLink className="ml-auto size-3.5" />
</a>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={requestWorkspaceSettings}>
<Settings className="size-4" />
{t('workspace.settings')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+2
View File
@@ -31,6 +31,8 @@ export interface CurrentWorkspace {
membership: WorkspaceMembership;
permissions: string[];
placement_generation: number;
/** Signed Cloud display metadata; never used for client-side authorization. */
plan_name?: string | null;
}
/** Account-scoped Workspace entry returned before a Workspace is selected. */
+17 -3
View File
@@ -19,7 +19,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
beginAuthenticatedSession,
bootstrapWorkspaceSession,
@@ -66,6 +66,7 @@ export default function Login() {
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)),
@@ -196,7 +197,7 @@ export default function Login() {
});
}
const handleSpaceLoginClick = async () => {
const handleSpaceLoginClick = useCallback(async () => {
setSpaceLoading(true);
try {
const currentOrigin = window.location.origin;
@@ -207,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 (
+4
View File
@@ -1293,6 +1293,10 @@ const enUS = {
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:
+4
View File
@@ -1227,6 +1227,10 @@ const zhHans = {
selectDescription: '选择你要进入的 LangBot 工作区。',
selectionLoadFailed: '无法加载你的工作区,请重试。',
switchWorkspace: '切换工作区',
settings: '工作区设置',
currentPlan: '当前计划',
planUnavailable: '暂不可用',
upgradePlan: '切换或升级计划',
ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription: