feat: harden tenant access and production operations

This commit is contained in:
xz
2026-08-24 22:02:22 +08:00
parent ffb27d845b
commit 9811469a64
18 changed files with 709 additions and 36 deletions
+209 -10
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
Archive,
@@ -11,6 +11,7 @@ import {
Clapperboard,
Coins,
Copy,
ChevronRight,
Download,
FileJson2,
Film,
@@ -83,7 +84,7 @@ import {
ScriptProductionPage,
SeriesBiblePage
} from "./components/ProductionPages";
import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api";
import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, searchPlatform, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api";
import { buildAllExports, buildModelRequest, downloadJson } from "./lib/exporters";
import { projectQa } from "./lib/qa";
import { getPlatformSummary, platformData } from "./platform/platformData";
@@ -688,7 +689,9 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
const usage = platform.usage;
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("writer");
const [inviteScope, setInviteScope] = useState(workspaces.length ? "workspace" : "organization");
const [inviteWorkspaceId, setInviteWorkspaceId] = useState(workspaces[0]?.id || "");
const [inviteProjectId, setInviteProjectId] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [workspaceName, setWorkspaceName] = useState("");
const [projectName, setProjectName] = useState("");
@@ -705,6 +708,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
const [inviteLink, setInviteLink] = useState("");
const [inviteActionId, setInviteActionId] = useState("");
const inviteProjects = projects.filter((item) => !inviteWorkspaceId || item.workspace_id === inviteWorkspaceId || item.workspaceId === inviteWorkspaceId);
const inviteRoleOptions = inviteScope === "organization"
? [["org_member", "组织成员"], ["org_admin", "组织管理员"]]
: inviteScope === "project"
? [["project_editor", "项目编辑"], ["project_viewer", "项目查看者"]]
: [["writer", "编剧"], ["producer", "制片"], ["art_director", "资产美术"], ["voice_editor", "配音/字幕"], ["reviewer", "审片"]];
useEffect(() => {
setMemberDrafts(Object.fromEntries(members.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "org_member", status: member.status || "active" }])));
}, [platform.members]);
@@ -723,9 +733,17 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
}, [context?.currentOrganization?.id, context?.currentWorkspace?.id, context?.currentProject?.id]);
useEffect(() => {
setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", status: member.status || "active" }])));
setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" }])));
}, [platform.workspaceMembers]);
useEffect(() => {
if (!workspaces.some((workspace) => workspace.id === inviteWorkspaceId)) setInviteWorkspaceId(workspaces[0]?.id || "");
}, [platform.workspaces]);
useEffect(() => {
if (!inviteProjects.some((project) => project.id === inviteProjectId)) setInviteProjectId(inviteProjects[0]?.id || "");
}, [platform.projects, inviteWorkspaceId]);
useEffect(() => {
const availableRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0];
if (availableRole && availableRole.key !== selectedPolicyRoleKey) setSelectedPolicyRoleKey(availableRole.key);
@@ -743,6 +761,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
}
}
function changeInviteScope(scope) {
setInviteScope(scope);
if (scope === "organization") setInviteRole("org_member");
else if (scope === "project") setInviteRole("project_editor");
else setInviteRole("writer");
}
function inviteUrl(invitation) {
if (!invitation) return "";
const path = invitation.acceptUrl || (invitation.inviteToken ? `/register?invite=${encodeURIComponent(invitation.inviteToken)}` : "");
@@ -753,7 +778,18 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
const handleInvite = async (event) => {
event.preventDefault();
const result = await runAction(() => onInvite({ email: inviteEmail, roleKey: inviteRole, workspaceId: inviteWorkspaceId || undefined }), "邀请已创建;注册链接仅在当前管理会话中显示");
if (inviteScope !== "organization" && !inviteWorkspaceId) {
setNotice("工作区级或项目级邀请必须选择工作区");
return;
}
if (inviteScope === "project" && !inviteProjectId) {
setNotice("项目级邀请必须选择项目");
return;
}
const body = { email: inviteEmail, roleKey: inviteRole };
if (inviteScope !== "organization") body.workspaceId = inviteWorkspaceId;
if (inviteScope === "project") body.projectId = inviteProjectId;
const result = await runAction(() => onInvite(body), "邀请已创建;注册链接仅在当前管理会话中显示");
if (result?.invitation) {
setInviteLink(inviteUrl(result.invitation));
setInviteEmail("");
@@ -978,8 +1014,10 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
</div>
<form className="invite-form" onSubmit={handleInvite}>
<div className="form-title"><UserPlus size={16} />邀请成员</div>
<div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色"><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="producer">制片</option></select></div>
<select value={inviteWorkspaceId} onChange={(event) => setInviteWorkspaceId(event.target.value)} aria-label="邀请工作区"><option value="">仅加入组织</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select>
<div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteScope} onChange={(event) => changeInviteScope(event.target.value)} aria-label="邀请范围"><option value="organization">组织级</option><option value="workspace" disabled={!workspaces.length}>工作区级</option><option value="project" disabled={!inviteProjects.length}>项目级</option></select></div>
{inviteScope !== "organization" && <select value={inviteWorkspaceId} onChange={(event) => { setInviteWorkspaceId(event.target.value); if (inviteScope === "project") setInviteProjectId(""); }} aria-label="邀请工作区"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select>}
{inviteScope === "project" && <select value={inviteProjectId} onChange={(event) => setInviteProjectId(event.target.value)} aria-label="邀请项目"><option value="">选择项目</option>{inviteProjects.map((project) => <option key={project.id} value={project.id}>{project.name || project.title}</option>)}</select>}
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色">{inviteRoleOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
<button className="subtle" type="submit"><Mail size={15} />发送邀请</button>
</form>
{inviteLink && <div className="invite-link-card">
@@ -1000,7 +1038,7 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
<div className="invite-list">
{invitations.length ? invitations.map((invite) => <div className="invite-item" key={invite.id}>
<Mail size={15} />
<div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div>
<div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.project_name ? `${invite.workspace_name} / ${invite.project_name}` : invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div>
<div className="invite-actions"><StatusPill tone="warn">pending</StatusPill><button type="button" className="icon-text-button" onClick={() => handleResendInvitation(invite)} disabled={inviteActionId === invite.id}><RefreshCw size={13} />重发</button><button type="button" className="icon-text-button danger" onClick={() => handleRevokeInvitation(invite)} disabled={inviteActionId === invite.id}><XCircle size={13} />撤销</button></div>
</div>) : <p className="empty-state">当前组织没有待处理邀请。</p>}
</div>
@@ -1011,8 +1049,8 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
<div className="member-list workspace-member-list">
{workspaceMembers.length ? workspaceMembers.map((member) => {
const userId = member.user_id || member.id;
const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", status: member.status || "active" };
return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>;
const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" };
return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="project_guest">项目受限成员</option></select><select aria-label={`${member.display_name} 项目访问范围`} value={draft.accessMode} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, accessMode: event.target.value } }))}><option value="all">全部项目</option><option value="project-only">仅授权项目</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>;
}) : <p className="empty-state">当前角色没有工作区成员管理权限。</p>}
</div>
</section>
@@ -1141,6 +1179,140 @@ function DirectorDesk({ project, activeShot, setActiveShotId }) {
);
}
const searchTypeIcons = {
project: FolderKanban,
episode: BookOpen,
script: BookOpen,
shot: Clapperboard,
asset: Images,
task: ListChecks,
job: Wand2,
delivery: Download
};
const searchStatusLabels = {
production: "制作中",
draft: "草稿",
ready: "就绪",
open: "待处理",
in_progress: "进行中",
blocked: "阻塞",
done: "已完成",
queued: "排队中",
running: "运行中",
completed: "已完成",
failed: "失败",
published: "已发布",
submitted: "待审批",
approved: "已批准"
};
function GlobalSearchPalette({ open, onClose, contextOverrides, onNavigate }) {
const inputRef = useRef(null);
const [query, setQuery] = useState("");
const [scope, setScope] = useState("workspace");
const [selectedIndex, setSelectedIndex] = useState(0);
const [payload, setPayload] = useState({ results: [], total: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return undefined;
const normalized = query.trim();
if (!normalized) {
setPayload({ results: [], total: 0 });
setSelectedIndex(0);
setError("");
return undefined;
}
let cancelled = false;
const timer = setTimeout(async () => {
setLoading(true);
try {
const next = await searchPlatform(normalized, { scope, limit: 40 }, contextOverrides);
if (!cancelled) {
setPayload(next);
setSelectedIndex(0);
setError("");
}
} catch (requestError) {
if (!cancelled) {
setPayload({ results: [], total: 0 });
setError(requestError.message || "搜索失败");
}
} finally {
if (!cancelled) setLoading(false);
}
}, 180);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [open, query, scope, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
useEffect(() => {
if (!open) return undefined;
function handleKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
onClose();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) => Math.min(current + 1, Math.max(0, payload.results.length - 1)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) => Math.max(0, current - 1));
} else if (event.key === "Enter" && payload.results[selectedIndex]) {
event.preventDefault();
onNavigate(payload.results[selectedIndex]);
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose, onNavigate, payload.results, selectedIndex]);
if (!open) return null;
const results = payload.results || [];
return (
<div className="global-search-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<section className="global-search-dialog" role="dialog" aria-modal="true" aria-label="全局搜索">
<div className="global-search-head">
<div className="global-search-input-wrap"><Search size={18} /><input ref={inputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、剧本、镜头、资产、任务…" aria-label="搜索项目、剧本、镜头、资产、任务" /></div>
<button className="icon-only-button" type="button" onClick={onClose} title="关闭搜索" aria-label="关闭搜索"><XCircle size={17} /></button>
</div>
<div className="global-search-toolbar">
<div className="global-search-scope" role="group" aria-label="搜索范围">
<button type="button" className={scope === "project" ? "selected" : ""} onClick={() => setScope("project")} disabled={!contextOverrides?.projectId}>当前项目</button>
<button type="button" className={scope === "workspace" ? "selected" : ""} onClick={() => setScope("workspace")}>当前工作区</button>
</div>
<span>{loading ? "搜索中…" : query.trim() ? `${payload.total || 0} 条结果` : "输入关键词开始搜索"}</span>
</div>
{error && <div className="global-search-error"><AlertTriangle size={15} />{error}</div>}
<div className="global-search-results">
{!query.trim() && <div className="global-search-empty"><Search size={24} /><strong>搜索整个生产工作区</strong><span>项目、剧本、分集、镜头、资产、任务、生成任务和交付记录都支持快速定位。</span></div>}
{query.trim() && !loading && !error && !results.length && <div className="global-search-empty"><Search size={24} /><strong>没有找到匹配内容</strong><span>试试项目名、镜头编号、角色名或任务关键词。</span></div>}
{results.map((result, index) => {
const Icon = searchTypeIcons[result.type] || Search;
return <button key={`${result.type}-${result.id}`} type="button" className={`global-search-result ${index === selectedIndex ? "selected" : ""}`} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { onNavigate(result); onClose(); }}>
<span className="global-search-result-icon"><Icon size={16} /></span>
<span className="global-search-result-copy"><strong>{result.title}</strong><small><b>{result.typeLabel}</b>{result.subtitle ? ` · ${result.subtitle}` : ""}</small></span>
{result.status && <span className="global-search-result-status">{searchStatusLabels[result.status] || result.status}</span>}
<ChevronRight size={15} />
</button>;
})}
</div>
<div className="global-search-foot"><span><kbd>↑</kbd><kbd>↓</kbd>选择</span><span><kbd>Enter</kbd>打开</span><span><kbd>Esc</kbd>关闭</span></div>
</section>
</div>
);
}
function App() {
const storedContext = getClientContext();
const storedAuthSession = getAuthSession();
@@ -1160,6 +1332,7 @@ function App() {
const [activeEpisodeId, setActiveEpisodeId] = useState(storedContext.episodeId || "");
const [activeTabState, setActiveTabState] = useState(tabFromLocation);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [activeShotId, setActiveShotId] = useState("");
const [adapterId, setAdapterId] = useState(emptyProject.production.defaultAdapter);
const [taskLog, setTaskLog] = useState([
@@ -1187,6 +1360,17 @@ function App() {
return () => window.removeEventListener("hashchange", handleHashChange);
}, []);
useEffect(() => {
function handleGlobalShortcut(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setSearchOpen(true);
}
}
window.addEventListener("keydown", handleGlobalShortcut);
return () => window.removeEventListener("keydown", handleGlobalShortcut);
}, []);
const activeTab = activeTabState;
const adapterCatalog = useMemo(() => adapterCatalogForContext(platformContext), [platformContext?.platform?.adapterCatalog, platformContext?.platform?.modelRegistry]);
@@ -1352,6 +1536,19 @@ function App() {
refreshContext({ organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId });
}
async function navigateFromSearch(result) {
const nextProjectId = result.projectId || (result.type === "project" ? result.id : activeProjectId);
await refreshContext({
organizationId: result.organizationId || activeOrgId,
workspaceId: result.workspaceId || activeWorkspaceId,
projectId: nextProjectId || ""
});
if (result.type === "shot" && typeof window !== "undefined") {
window.setTimeout(() => setActiveShotId(result.id), 0);
}
setActiveTab(result.targetTab || "creator-home");
}
async function inviteFromAdmin(body) {
const result = await inviteMember(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
@@ -1597,6 +1794,7 @@ function App() {
<label><FolderKanban size={14} /><span>工作区</span><select value={activeWorkspaceId} onChange={(event) => switchWorkspace(event.target.value)} disabled={contextLoading || !workspaces.length} aria-label="工作区切换"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select></label>
<label><Film size={14} /><span>项目</span><select value={activeProjectId} onChange={(event) => switchProject(event.target.value)} disabled={contextLoading || !projects.length} aria-label="项目切换"><option value="">选择项目</option>{projects.map((projectItem) => <option key={projectItem.id} value={projectItem.id}>{projectItem.title || projectItem.name}</option>)}</select></label>
</div>
<button className="topbar-search-trigger" type="button" onClick={() => setSearchOpen(true)} title="打开全局搜索"><Search size={15} /><span>搜索生产内容</span><kbd>⌘K</kbd></button>
<NotificationBell contextOverrides={contextOverrides} onOpen={setActiveTab} />
<div className="user-context"><span className="user-avatar">{currentUser?.display_name?.slice(0, 1) || "林"}</span><div><strong>{currentUser?.display_name || "本地用户"}</strong><span>{currentRole}</span></div><button className="icon-only-button" title="账号与安全" aria-label="账号与安全" onClick={() => setActiveTab("account")}><KeyRound size={15} /></button><button className="icon-only-button" title="退出登录" aria-label="退出登录" onClick={handleLogout}><LogOut size={15} /></button></div>
{canCreateJobNow && <select value={adapterId} onChange={(event) => setAdapterId(event.target.value)} aria-label="生成适配器">
@@ -1679,7 +1877,7 @@ function App() {
{activeTab === "delivery-portal" && <DeliveryAccessPage contextOverrides={contextOverrides} canApproveDelivery={canApproveDelivery} />}
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} setActiveTab={setActiveTab} />}
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
@@ -1732,6 +1930,7 @@ function App() {
</aside>}
</div>
</main>
<GlobalSearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} contextOverrides={contextOverrides} onNavigate={navigateFromSearch} />
</div>
);
}