mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): use readonly Organization[] and explicit is_default lookups (#24288)
`OrganizationAutocomplete` declared `options: Organization[]` (mutable), but `useDashboard().organizations` returns `readonly Organization[]`. Callers forced to spread to satisfy the type even though the component never mutates the array. While fixing this, found two more issues with how organization context is resolved: - `AgentCreateForm`, `ChatPageContent`, and `OrganizationRedirect` used `organizations[0]` assuming the default org is first — nothing guarantees that ordering. - `ChatPageInput` was guessing the org from `useDashboard()` for file uploads on existing chats, even though the chat already has `organization_id`. In multi-org deployments, uploads could land in the wrong org. Changes: - Widen `OrganizationAutocomplete` `options` to `readonly Organization[]` - Remove unnecessary `[...organizations]` spread in `AgentCreateForm` - Replace all `organizations[0]` with `.find(o => o.is_default) ?? organizations[0]` for users not in the default org - Eliminate `[...organizations].sort()` allocation in `OrganizationRedirect` with direct `.find()` lookups (equivalent priority: editable default → any editable → viewable default → any viewable) - Thread `chat.organization_id` from `AgentChatPage` → `AgentChatPageView` → `ChatPageInput` so file uploads use the chat's actual org instead of guessing Closes #24285 > 🤖 PR initially created by Claude Opus 4.6
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
type OrganizationAutocompleteProps = {
|
||||
value: Organization | null;
|
||||
onChange: (organization: Organization | null) => void;
|
||||
options: Organization[];
|
||||
options: readonly Organization[];
|
||||
id?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
@@ -1233,6 +1233,7 @@ const AgentChatPage: FC = () => {
|
||||
return (
|
||||
<AgentChatPageView
|
||||
agentId={agentId}
|
||||
organizationId={chatQuery.data?.organization_id}
|
||||
chatTitle={chatTitle}
|
||||
parentChat={parentChat}
|
||||
persistedError={persistedError}
|
||||
|
||||
@@ -113,6 +113,7 @@ type StoryProps = Omit<
|
||||
const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
|
||||
const props = {
|
||||
agentId: AGENT_ID,
|
||||
organizationId: "test-org-id",
|
||||
chatTitle: "Help me refactor",
|
||||
persistedError: undefined as ChatDetailError | undefined,
|
||||
parentChat: undefined as TypesGen.Chat | undefined,
|
||||
|
||||
@@ -85,6 +85,7 @@ interface EditingState {
|
||||
interface AgentChatPageViewProps {
|
||||
// Chat data.
|
||||
agentId: string;
|
||||
organizationId: string | undefined;
|
||||
chatTitle: string | undefined;
|
||||
parentChat: TypesGen.Chat | undefined;
|
||||
persistedError: ChatDetailError | undefined;
|
||||
@@ -176,6 +177,7 @@ interface AgentChatPageViewProps {
|
||||
|
||||
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
agentId,
|
||||
organizationId,
|
||||
chatTitle,
|
||||
parentChat,
|
||||
persistedError,
|
||||
@@ -417,6 +419,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
</ChatScrollContainer>
|
||||
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<ChatPageInput
|
||||
organizationId={organizationId}
|
||||
store={store}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onSend={editing.handleSendFromInput}
|
||||
|
||||
@@ -186,6 +186,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
|
||||
? userSelectedModel
|
||||
: preferredModelID;
|
||||
const initialOrg =
|
||||
organizations.find((o) => o.is_default) ?? organizations[0];
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
|
||||
() => {
|
||||
const stored = localStorage.getItem(selectedWorkspaceIdStorageKey);
|
||||
@@ -207,8 +209,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
}
|
||||
if (
|
||||
showOrganizations &&
|
||||
organizations[0] &&
|
||||
workspace.organization_id !== organizations[0].id
|
||||
initialOrg &&
|
||||
workspace.organization_id !== initialOrg.id
|
||||
) {
|
||||
localStorage.removeItem(selectedWorkspaceIdStorageKey);
|
||||
return null;
|
||||
@@ -217,7 +219,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
},
|
||||
);
|
||||
const [selectedOrg, setSelectedOrg] = useState<TypesGen.Organization | null>(
|
||||
organizations[0] ?? null,
|
||||
initialOrg ?? null,
|
||||
);
|
||||
const [pendingOrgChange, setPendingOrgChange] =
|
||||
useState<TypesGen.Organization | null>(null);
|
||||
@@ -404,7 +406,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
id="organization"
|
||||
required
|
||||
value={selectedOrg}
|
||||
options={[...organizations]}
|
||||
options={organizations}
|
||||
onChange={(newOrg) => {
|
||||
const orgChanged = newOrg?.id !== selectedOrg?.id;
|
||||
if (orgChanged && attachments.length > 0) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type FC, Profiler, type ReactNode, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { UrlTransform } from "streamdown";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { useFileAttachments } from "../hooks/useFileAttachments";
|
||||
import type { ChatDetailError } from "../utils/usageLimitMessage";
|
||||
import {
|
||||
@@ -124,6 +123,8 @@ export type PendingAttachment = {
|
||||
};
|
||||
|
||||
interface ChatPageInputProps {
|
||||
// Organization that owns this chat. Used to scope file uploads.
|
||||
organizationId: string | undefined;
|
||||
store: ChatStoreHandle;
|
||||
compressionThreshold: number | undefined;
|
||||
onSend: (
|
||||
@@ -181,6 +182,7 @@ interface ChatPageInputProps {
|
||||
}
|
||||
|
||||
export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
organizationId,
|
||||
store,
|
||||
compressionThreshold,
|
||||
onSend,
|
||||
@@ -257,8 +259,6 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
const latestContextUsage = rawUsage
|
||||
? { ...rawUsage, compressionThreshold, lastInjectedContext }
|
||||
: rawUsage;
|
||||
const { organizations } = useDashboard();
|
||||
const organizationId = organizations[0]?.id;
|
||||
const {
|
||||
attachments,
|
||||
textContents,
|
||||
|
||||
@@ -10,25 +10,25 @@ const OrganizationRedirect: FC = () => {
|
||||
organizationPermissionsByOrganizationId: organizationPermissions,
|
||||
} = useOrganizationSettings();
|
||||
|
||||
const sortedOrganizations = [...organizations].sort(
|
||||
(a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0),
|
||||
);
|
||||
|
||||
// Redirect /organizations => /organizations/some-organization-name
|
||||
// If they can edit the default org, we should redirect to the default.
|
||||
// If they cannot edit the default, we should redirect to the first org that
|
||||
// they can edit.
|
||||
const editableOrg = sortedOrganizations.find((org) =>
|
||||
canEditOrganization(organizationPermissions[org.id]),
|
||||
);
|
||||
// Prefer the editable default org, then any editable org, then
|
||||
// any viewable org. This replaces the previous [...organizations]
|
||||
// .sort() approach with direct lookups to avoid copying the array.
|
||||
const defaultOrg = organizations.find((org) => org.is_default);
|
||||
const editableOrg =
|
||||
(defaultOrg &&
|
||||
canEditOrganization(organizationPermissions[defaultOrg.id]) &&
|
||||
defaultOrg) ||
|
||||
organizations.find((org) =>
|
||||
canEditOrganization(organizationPermissions[org.id]),
|
||||
);
|
||||
if (editableOrg) {
|
||||
return <Navigate to={`/organizations/${editableOrg.name}`} replace />;
|
||||
}
|
||||
// If they cannot edit any org, just redirect to an org they can read.
|
||||
if (sortedOrganizations.length > 0) {
|
||||
return (
|
||||
<Navigate to={`/organizations/${sortedOrganizations[0].name}`} replace />
|
||||
);
|
||||
// If they cannot edit any org, just redirect to one they can read.
|
||||
const viewableOrg = defaultOrg ?? organizations[0];
|
||||
if (viewableOrg) {
|
||||
return <Navigate to={`/organizations/${viewableOrg.name}`} replace />;
|
||||
}
|
||||
return <EmptyState message="No organizations found" />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user