mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
refactor: remove ChooseOne component (#24983)
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ChooseOne, Cond } from "./ChooseOne";
|
||||
|
||||
const meta: Meta<typeof ChooseOne> = {
|
||||
title: "components/Conditionals/ChooseOne",
|
||||
component: ChooseOne,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ChooseOne>;
|
||||
|
||||
export const FirstIsTrue: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Cond key="1" condition>
|
||||
The first one shows.
|
||||
</Cond>,
|
||||
<Cond key="2" condition={false}>
|
||||
The second one does not show.
|
||||
</Cond>,
|
||||
<Cond key="3">The default does not show.</Cond>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SecondIsTrue: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Cond key="1" condition={false}>
|
||||
The first one does not show.
|
||||
</Cond>,
|
||||
<Cond key="2" condition>
|
||||
The second one shows.
|
||||
</Cond>,
|
||||
<Cond key="3">The default does not show.</Cond>,
|
||||
],
|
||||
},
|
||||
};
|
||||
export const AllAreTrue: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Cond key="1" condition>
|
||||
Only the first one shows.
|
||||
</Cond>,
|
||||
<Cond key="2" condition>
|
||||
The second one does not show.
|
||||
</Cond>,
|
||||
<Cond key="3">The default does not show.</Cond>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoneAreTrue: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Cond key="1" condition={false}>
|
||||
The first one does not show.
|
||||
</Cond>,
|
||||
<Cond key="2" condition={false}>
|
||||
The second one does not show.
|
||||
</Cond>,
|
||||
<Cond key="3">The default shows.</Cond>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const OneCond: Story = {
|
||||
args: {
|
||||
children: <Cond>An only child renders.</Cond>,
|
||||
},
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
Children,
|
||||
type FC,
|
||||
type JSX,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface CondProps {
|
||||
condition?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that attaches a condition to a child component so that ChooseOne can
|
||||
* determine which child to render. The last Cond in a ChooseOne is the fallback case and
|
||||
* should not have a condition.
|
||||
* @param condition boolean expression indicating whether the child should be rendered, or undefined
|
||||
* @returns child. Note that Cond alone does not enforce the condition; it should be used inside ChooseOne.
|
||||
* @deprecated Use standard conditional rendering (ternary operators or && expressions) instead.
|
||||
*/
|
||||
export const Cond: FC<CondProps> = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper component for rendering exactly one of its children. Wrap each child in Cond to associate it
|
||||
* with a condition under which it should be rendered. If no conditions are met, the final child
|
||||
* will be rendered.
|
||||
* @returns one of its children, or null if there are no children
|
||||
* @throws an error if its last child has a condition prop, or any non-final children do not have a condition prop
|
||||
* @deprecated Use standard conditional rendering (ternary operators or && expressions) instead.
|
||||
*/
|
||||
export const ChooseOne: FC<PropsWithChildren> = ({ children }) => {
|
||||
const childArray = Children.toArray(children) as JSX.Element[];
|
||||
if (childArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const conditionedOptions = childArray.slice(0, childArray.length - 1);
|
||||
const defaultCase = childArray[childArray.length - 1];
|
||||
if (defaultCase.props.condition !== undefined) {
|
||||
throw new Error(
|
||||
"The last Cond in a ChooseOne was given a condition prop, but it is the default case.",
|
||||
);
|
||||
}
|
||||
if (conditionedOptions.some((cond) => cond.props.condition === undefined)) {
|
||||
throw new Error(
|
||||
"A non-final Cond in a ChooseOne does not have a condition prop or the prop is undefined.",
|
||||
);
|
||||
}
|
||||
const chosen = conditionedOptions.find((child) => child.props.condition);
|
||||
return chosen ?? defaultCase;
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentDevcontainer,
|
||||
} from "#/api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
HelpPopover,
|
||||
HelpPopoverContent,
|
||||
@@ -195,34 +194,28 @@ const ConnectedStatus: FC<AgentStatusProps> = ({ agent }) => {
|
||||
if (agent.scripts.length === 0) {
|
||||
return <ReadyLifecycle />;
|
||||
}
|
||||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={agent.lifecycle_state === "ready"}>
|
||||
<ReadyLifecycle />
|
||||
</Cond>
|
||||
<Cond condition={agent.lifecycle_state === "start_timeout"}>
|
||||
<StartTimeoutLifecycle agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.lifecycle_state === "start_error"}>
|
||||
<StartErrorLifecycle agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.lifecycle_state === "shutting_down"}>
|
||||
<ShuttingDownLifecycle />
|
||||
</Cond>
|
||||
<Cond condition={agent.lifecycle_state === "shutdown_timeout"}>
|
||||
<ShutdownTimeoutLifecycle agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.lifecycle_state === "shutdown_error"}>
|
||||
<ShutdownErrorLifecycle agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.lifecycle_state === "off"}>
|
||||
<OffLifecycle />
|
||||
</Cond>
|
||||
<Cond>
|
||||
<StartingLifecycle />
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
);
|
||||
if (agent.lifecycle_state === "ready") {
|
||||
return <ReadyLifecycle />;
|
||||
}
|
||||
if (agent.lifecycle_state === "start_timeout") {
|
||||
return <StartTimeoutLifecycle agent={agent} />;
|
||||
}
|
||||
if (agent.lifecycle_state === "start_error") {
|
||||
return <StartErrorLifecycle agent={agent} />;
|
||||
}
|
||||
if (agent.lifecycle_state === "shutting_down") {
|
||||
return <ShuttingDownLifecycle />;
|
||||
}
|
||||
if (agent.lifecycle_state === "shutdown_timeout") {
|
||||
return <ShutdownTimeoutLifecycle agent={agent} />;
|
||||
}
|
||||
if (agent.lifecycle_state === "shutdown_error") {
|
||||
return <ShutdownErrorLifecycle agent={agent} />;
|
||||
}
|
||||
if (agent.lifecycle_state === "off") {
|
||||
return <OffLifecycle />;
|
||||
}
|
||||
return <StartingLifecycle />;
|
||||
};
|
||||
|
||||
const DisconnectedStatus: FC = () => {
|
||||
@@ -265,44 +258,32 @@ const TimeoutStatus: FC<AgentStatusProps> = ({ agent }) => (
|
||||
);
|
||||
|
||||
export const AgentStatus: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={agent.status === "connected"}>
|
||||
<ConnectedStatus agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.status === "disconnected"}>
|
||||
<DisconnectedStatus />
|
||||
</Cond>
|
||||
<Cond condition={agent.status === "timeout"}>
|
||||
<TimeoutStatus agent={agent} />
|
||||
</Cond>
|
||||
<Cond>
|
||||
<ConnectingStatus />
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
);
|
||||
if (agent.status === "connected") {
|
||||
return <ConnectedStatus agent={agent} />;
|
||||
}
|
||||
if (agent.status === "disconnected") {
|
||||
return <DisconnectedStatus />;
|
||||
}
|
||||
if (agent.status === "timeout") {
|
||||
return <TimeoutStatus agent={agent} />;
|
||||
}
|
||||
return <ConnectingStatus />;
|
||||
};
|
||||
|
||||
const SubAgentStatus: FC<SubAgentStatusProps> = ({ agent }) => {
|
||||
if (!agent) {
|
||||
return <DisconnectedStatus />;
|
||||
}
|
||||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={agent.status === "connected"}>
|
||||
<ConnectedStatus agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.status === "disconnected"}>
|
||||
<DisconnectedStatus />
|
||||
</Cond>
|
||||
<Cond condition={agent.status === "timeout"}>
|
||||
<TimeoutStatus agent={agent} />
|
||||
</Cond>
|
||||
<Cond>
|
||||
<ConnectingStatus />
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
);
|
||||
if (agent.status === "connected") {
|
||||
return <ConnectedStatus agent={agent} />;
|
||||
}
|
||||
if (agent.status === "disconnected") {
|
||||
return <DisconnectedStatus />;
|
||||
}
|
||||
if (agent.status === "timeout") {
|
||||
return <TimeoutStatus agent={agent} />;
|
||||
}
|
||||
return <ConnectingStatus />;
|
||||
};
|
||||
|
||||
const DevcontainerStartError: FC<AgentStatusProps> = ({ agent }) => (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComponentProps, FC } from "react";
|
||||
import type { AuditLog } from "#/api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { Margins } from "#/components/Margins/Margins";
|
||||
import {
|
||||
@@ -63,8 +62,8 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
<PageHeaderSubtitle>View events in your audit log.</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<ChooseOne>
|
||||
<Cond condition={isAuditLogVisible}>
|
||||
{isAuditLogVisible ? (
|
||||
<>
|
||||
<AuditFilter {...filterProps} />
|
||||
|
||||
<PaginationContainer
|
||||
@@ -73,69 +72,85 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
{/* Error condition should just show an empty table. */}
|
||||
<Cond condition={Boolean(error)}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="An error occurred while loading audit logs" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<ChooseOne>
|
||||
<Cond condition={isNonInitialPage}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No audit logs available on this page" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No audit logs available" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{auditLogs && (
|
||||
<Timeline
|
||||
items={auditLogs}
|
||||
getDate={(log) => new Date(log.time)}
|
||||
row={(log) => (
|
||||
<AuditLogRow
|
||||
key={log.id}
|
||||
auditLog={log}
|
||||
showOrgDetails={showOrgDetails}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
<AuditTableBody
|
||||
auditLogs={auditLogs}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
isEmpty={isEmpty}
|
||||
isNonInitialPage={isNonInitialPage}
|
||||
showOrgDetails={showOrgDetails}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PaginationContainer>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<PaywallPremium
|
||||
message="Audit logs"
|
||||
description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/security/audit-logs")}
|
||||
/>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</>
|
||||
) : (
|
||||
<PaywallPremium
|
||||
message="Audit logs"
|
||||
description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/security/audit-logs")}
|
||||
/>
|
||||
)}
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
interface AuditTableBodyProps {
|
||||
auditLogs: readonly AuditLog[] | undefined;
|
||||
error: unknown;
|
||||
isLoading: boolean;
|
||||
isEmpty: boolean;
|
||||
isNonInitialPage: boolean;
|
||||
showOrgDetails: boolean;
|
||||
}
|
||||
|
||||
const AuditTableBody: FC<AuditTableBodyProps> = ({
|
||||
auditLogs,
|
||||
error,
|
||||
isLoading,
|
||||
isEmpty,
|
||||
isNonInitialPage,
|
||||
showOrgDetails,
|
||||
}) => {
|
||||
// An error renders as an empty table.
|
||||
if (error) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="An error occurred while loading audit logs" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
if (isEmpty) {
|
||||
const emptyMessage = isNonInitialPage
|
||||
? "No audit logs available on this page"
|
||||
: "No audit logs available";
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message={emptyMessage} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (!auditLogs) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Timeline
|
||||
items={auditLogs}
|
||||
getDate={(log) => new Date(log.time)}
|
||||
row={(log) => (
|
||||
<AuditLogRow
|
||||
key={log.id}
|
||||
auditLog={log}
|
||||
showOrgDetails={showOrgDetails}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComponentProps, FC } from "react";
|
||||
import type { ConnectionLog } from "#/api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { Margins } from "#/components/Margins/Margins";
|
||||
import {
|
||||
@@ -64,8 +63,8 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<ChooseOne>
|
||||
<Cond condition={isConnectionLogVisible}>
|
||||
{isConnectionLogVisible ? (
|
||||
<>
|
||||
<ConnectionLogFilter {...filterProps} />
|
||||
|
||||
<PaginationContainer
|
||||
@@ -74,65 +73,76 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
{/* Error condition should just show an empty table. */}
|
||||
<Cond condition={Boolean(error)}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="An error occurred while loading connection logs" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<ChooseOne>
|
||||
<Cond condition={isNonInitialPage}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No connection logs available on this page" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No connection logs available" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{connectionLogs && (
|
||||
<Timeline
|
||||
items={connectionLogs}
|
||||
getDate={(log) => new Date(log.connect_time)}
|
||||
row={(log) => (
|
||||
<ConnectionLogRow key={log.id} connectionLog={log} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
<ConnectionLogTableBody
|
||||
connectionLogs={connectionLogs}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
isEmpty={isEmpty}
|
||||
isNonInitialPage={isNonInitialPage}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PaginationContainer>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<PaywallPremium
|
||||
message="Connection logs"
|
||||
description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/monitoring/connection-logs")}
|
||||
/>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</>
|
||||
) : (
|
||||
<PaywallPremium
|
||||
message="Connection logs"
|
||||
description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/monitoring/connection-logs")}
|
||||
/>
|
||||
)}
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConnectionLogTableBodyProps {
|
||||
connectionLogs: readonly ConnectionLog[] | undefined;
|
||||
error: unknown;
|
||||
isLoading: boolean;
|
||||
isEmpty: boolean;
|
||||
isNonInitialPage: boolean;
|
||||
}
|
||||
|
||||
const ConnectionLogTableBody: FC<ConnectionLogTableBodyProps> = ({
|
||||
connectionLogs,
|
||||
error,
|
||||
isLoading,
|
||||
isEmpty,
|
||||
isNonInitialPage,
|
||||
}) => {
|
||||
// An error renders as an empty table.
|
||||
if (error) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="An error occurred while loading connection logs" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
if (isEmpty) {
|
||||
const emptyMessage = isNonInitialPage
|
||||
? "No connection logs available on this page"
|
||||
: "No connection logs available";
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message={emptyMessage} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (!connectionLogs) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Timeline
|
||||
items={connectionLogs}
|
||||
getDate={(log) => new Date(log.connect_time)}
|
||||
row={(log) => <ConnectionLogRow key={log.id} connectionLog={log} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
organizationIdpSyncSettings,
|
||||
patchOrganizationSyncSettings,
|
||||
} from "#/api/queries/idpsync";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
@@ -76,40 +75,37 @@ const IdpOrgSyncPage: FC = () => {
|
||||
</div>
|
||||
<ExportPolicyButton syncSettings={settingsQuery.data} />
|
||||
</header>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isIdpSyncEnabled}>
|
||||
<PaywallPremium
|
||||
message="IdP Organization Sync"
|
||||
description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/idp-sync")}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<IdpOrgSyncPageView
|
||||
organizationSyncSettings={settingsQuery.data}
|
||||
claimFieldValues={fieldValuesQuery.data}
|
||||
organizations={organizations}
|
||||
onSyncFieldChange={setField}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await patchOrganizationSyncSettingsMutation.mutateAsync(data);
|
||||
toast.success("Organization sync settings updated.");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"Failed to update organization IdP sync settings.",
|
||||
),
|
||||
{
|
||||
description: getErrorDetail(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
error={settingsQuery.error || fieldValuesQuery.error}
|
||||
/>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
{!isIdpSyncEnabled ? (
|
||||
<PaywallPremium
|
||||
message="IdP Organization Sync"
|
||||
description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/idp-sync")}
|
||||
/>
|
||||
) : (
|
||||
<IdpOrgSyncPageView
|
||||
organizationSyncSettings={settingsQuery.data}
|
||||
claimFieldValues={fieldValuesQuery.data}
|
||||
organizations={organizations}
|
||||
onSyncFieldChange={setField}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await patchOrganizationSyncSettingsMutation.mutateAsync(data);
|
||||
toast.success("Organization sync settings updated.");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"Failed to update organization IdP sync settings.",
|
||||
),
|
||||
{
|
||||
description: getErrorDetail(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
error={settingsQuery.error || fieldValuesQuery.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from "#/components/Combobox/Combobox";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -408,27 +407,23 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No organization mappings"
|
||||
isCompact
|
||||
cta={
|
||||
<Link
|
||||
href={docs("/admin/users/idp-sync#organization-sync")}
|
||||
>
|
||||
How to set up IdP organization sync
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>{children}</Cond>
|
||||
</ChooseOne>
|
||||
{isEmpty ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No organization mappings"
|
||||
isCompact
|
||||
cta={
|
||||
<Link href={docs("/admin/users/idp-sync#organization-sync")}>
|
||||
How to set up IdP organization sync
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
@@ -37,68 +36,76 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
canCreateGroup,
|
||||
groupsEnabled,
|
||||
}) => {
|
||||
const isLoading = Boolean(groups === undefined);
|
||||
const isEmpty = Boolean(groups && groups.length === 0);
|
||||
if (!groupsEnabled) {
|
||||
return (
|
||||
<PaywallPremium
|
||||
message="Groups"
|
||||
description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/groups-roles")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={!groupsEnabled}>
|
||||
<PaywallPremium
|
||||
message="Groups"
|
||||
description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/groups-roles")}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/5">Name</TableHead>
|
||||
<TableHead className="w-3/5">Users</TableHead>
|
||||
<TableHead className="w-auto" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/5">Name</TableHead>
|
||||
<TableHead className="w-3/5">Users</TableHead>
|
||||
<TableHead className="w-auto" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<GroupsTableBody groups={groups} canCreateGroup={canCreateGroup} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No groups yet"
|
||||
description={
|
||||
canCreateGroup
|
||||
? "Create your first group"
|
||||
: "You don't have permission to create a group"
|
||||
}
|
||||
cta={
|
||||
canCreateGroup && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<PlusIcon className="size-icon-sm" />
|
||||
Create group
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
interface GroupsTableBodyProps {
|
||||
groups: Group[] | undefined;
|
||||
canCreateGroup: boolean;
|
||||
}
|
||||
|
||||
<Cond>
|
||||
{groups?.map((group) => (
|
||||
<GroupRow key={group.id} group={group} />
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
const GroupsTableBody: FC<GroupsTableBodyProps> = ({
|
||||
groups,
|
||||
canCreateGroup,
|
||||
}) => {
|
||||
if (groups === undefined) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No groups yet"
|
||||
description={
|
||||
canCreateGroup
|
||||
? "Create your first group"
|
||||
: "You don't have permission to create a group"
|
||||
}
|
||||
cta={
|
||||
canCreateGroup && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<PlusIcon className="size-icon-sm" />
|
||||
Create group
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<GroupRow key={group.id} group={group} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { CreateOrganizationRequest } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Badges, PremiumBadge } from "#/components/Badges/Badges";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall";
|
||||
@@ -112,67 +111,64 @@ export const CreateOrganizationPageView: FC<
|
||||
</p>
|
||||
</header>
|
||||
</div>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isEntitled}>
|
||||
<div className="min-w-fit mx-auto">
|
||||
<PaywallPremium
|
||||
message="Organizations"
|
||||
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
|
||||
documentationLink={docs("/admin/users/organizations")}
|
||||
/>
|
||||
</div>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<div className="flex flex-col gap-4 w-full max-w-xl min-w-72 mx-auto">
|
||||
<form
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
className="flex flex-col gap-6 w-full"
|
||||
{!isEntitled ? (
|
||||
<div className="min-w-fit mx-auto">
|
||||
<PaywallPremium
|
||||
message="Organizations"
|
||||
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
|
||||
documentationLink={docs("/admin/users/organizations")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-xl min-w-72 mx-auto">
|
||||
<form
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="flex flex-col gap-6 w-full border-none"
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="flex flex-col gap-6 w-full border-none"
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label="Slug"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
fullWidth
|
||||
label="Display name"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
multiline
|
||||
label="Description"
|
||||
rows={2}
|
||||
/>
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
{form.isSubmitting && <Spinner />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => navigate("/organizations")}
|
||||
>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label="Slug"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
fullWidth
|
||||
label="Display name"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
multiline
|
||||
label="Description"
|
||||
rows={2}
|
||||
/>
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
{form.isSubmitting && <Spinner />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => navigate("/organizations")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { FC } from "react";
|
||||
import { Link as RouterLink, useNavigate } from "react-router";
|
||||
import type { AssignableRoles, Role } from "#/api/typesGenerated";
|
||||
import { Button, Button as ShadcnButton } from "#/components/Button/Button";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -117,8 +116,6 @@ const RoleTable: FC<RoleTableProps> = ({
|
||||
canDeleteOrgRole,
|
||||
onDeleteRole,
|
||||
}) => {
|
||||
const isLoading = roles === undefined;
|
||||
const isEmpty = Boolean(roles && roles.length === 0);
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -129,58 +126,76 @@ const RoleTable: FC<RoleTableProps> = ({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow className="h-14">
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No custom roles yet"
|
||||
description={
|
||||
canCreateOrgRole && isCustomRolesEnabled
|
||||
? "Create your first custom role"
|
||||
: !isCustomRolesEnabled
|
||||
? "Upgrade to a premium license to create a custom role"
|
||||
: "You don't have permission to create a custom role"
|
||||
}
|
||||
cta={
|
||||
canCreateOrgRole &&
|
||||
isCustomRolesEnabled && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<PlusIcon />
|
||||
Create custom role
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{[...(roles ?? [])]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((role) => (
|
||||
<RoleRow
|
||||
key={role.name}
|
||||
role={role}
|
||||
canUpdateOrgRole={canUpdateOrgRole}
|
||||
canDeleteOrgRole={canDeleteOrgRole}
|
||||
onDelete={() => onDeleteRole(role)}
|
||||
/>
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
<RoleTableBody
|
||||
roles={roles}
|
||||
isCustomRolesEnabled={isCustomRolesEnabled}
|
||||
canCreateOrgRole={canCreateOrgRole}
|
||||
canUpdateOrgRole={canUpdateOrgRole}
|
||||
canDeleteOrgRole={canDeleteOrgRole}
|
||||
onDeleteRole={onDeleteRole}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const RoleTableBody: FC<RoleTableProps> = ({
|
||||
roles,
|
||||
isCustomRolesEnabled,
|
||||
canCreateOrgRole,
|
||||
canUpdateOrgRole,
|
||||
canDeleteOrgRole,
|
||||
onDeleteRole,
|
||||
}) => {
|
||||
if (roles === undefined) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
if (roles.length === 0) {
|
||||
return (
|
||||
<TableRow className="h-14">
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No custom roles yet"
|
||||
description={
|
||||
canCreateOrgRole && isCustomRolesEnabled
|
||||
? "Create your first custom role"
|
||||
: !isCustomRolesEnabled
|
||||
? "Upgrade to a premium license to create a custom role"
|
||||
: "You don't have permission to create a custom role"
|
||||
}
|
||||
cta={
|
||||
canCreateOrgRole &&
|
||||
isCustomRolesEnabled && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<PlusIcon />
|
||||
Create custom role
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{[...roles]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((role) => (
|
||||
<RoleRow
|
||||
key={role.name}
|
||||
role={role}
|
||||
canUpdateOrgRole={canUpdateOrgRole}
|
||||
canDeleteOrgRole={canDeleteOrgRole}
|
||||
onDelete={() => onDeleteRole(role)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoleRowProps {
|
||||
role: AssignableRoles;
|
||||
canUpdateOrgRole: boolean;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import {
|
||||
@@ -37,28 +36,27 @@ export const IdpMappingTable: FC<IdpMappingTableProps> = ({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={rowCount === 0}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message={`No ${type.toLocaleLowerCase()} mappings`}
|
||||
isCompact
|
||||
cta={
|
||||
<Link
|
||||
href={docs(
|
||||
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
|
||||
)}
|
||||
>
|
||||
How to setup IdP {type.toLocaleLowerCase()} sync
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
<Cond>{children}</Cond>
|
||||
</ChooseOne>
|
||||
{rowCount === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message={`No ${type.toLocaleLowerCase()} mappings`}
|
||||
isCompact
|
||||
cta={
|
||||
<Link
|
||||
href={docs(
|
||||
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
|
||||
)}
|
||||
>
|
||||
How to setup IdP {type.toLocaleLowerCase()} sync
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
roleIdpSyncSettings,
|
||||
} from "#/api/queries/organizations";
|
||||
import { organizationRoles } from "#/api/queries/roles";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
@@ -128,61 +127,57 @@ const IdpSyncPage: FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isIdpSyncEnabled}>
|
||||
<PaywallPremium
|
||||
message="IdP Sync"
|
||||
description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/idp-sync")}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<IdpSyncPageView
|
||||
tab={tab}
|
||||
groupSyncSettings={groupIdpSyncSettingsQuery.data}
|
||||
roleSyncSettings={roleIdpSyncSettingsQuery.data}
|
||||
claimFieldValues={fieldValuesQuery.data}
|
||||
groups={groupsQuery.data}
|
||||
groupsMap={groupsMap}
|
||||
roles={rolesQuery.data}
|
||||
organization={organization}
|
||||
onGroupSyncFieldChange={setGroupField}
|
||||
onRoleSyncFieldChange={setRoleField}
|
||||
error={error}
|
||||
onSubmitGroupSyncSettings={async (data) => {
|
||||
const mutation =
|
||||
patchGroupSyncSettingsMutation.mutateAsync(data);
|
||||
toast.promise(mutation, {
|
||||
loading: "Updating IdP group sync settings...",
|
||||
success: "IdP group sync settings updated.",
|
||||
error: (error) => ({
|
||||
message: getErrorMessage(
|
||||
error,
|
||||
"Failed to update IdP group sync settings.",
|
||||
),
|
||||
{!isIdpSyncEnabled ? (
|
||||
<PaywallPremium
|
||||
message="IdP Sync"
|
||||
description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/idp-sync")}
|
||||
/>
|
||||
) : (
|
||||
<IdpSyncPageView
|
||||
tab={tab}
|
||||
groupSyncSettings={groupIdpSyncSettingsQuery.data}
|
||||
roleSyncSettings={roleIdpSyncSettingsQuery.data}
|
||||
claimFieldValues={fieldValuesQuery.data}
|
||||
groups={groupsQuery.data}
|
||||
groupsMap={groupsMap}
|
||||
roles={rolesQuery.data}
|
||||
organization={organization}
|
||||
onGroupSyncFieldChange={setGroupField}
|
||||
onRoleSyncFieldChange={setRoleField}
|
||||
error={error}
|
||||
onSubmitGroupSyncSettings={async (data) => {
|
||||
const mutation = patchGroupSyncSettingsMutation.mutateAsync(data);
|
||||
toast.promise(mutation, {
|
||||
loading: "Updating IdP group sync settings...",
|
||||
success: "IdP group sync settings updated.",
|
||||
error: (error) => ({
|
||||
message: getErrorMessage(
|
||||
error,
|
||||
"Failed to update IdP group sync settings.",
|
||||
),
|
||||
description: getErrorDetail(error),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
onSubmitRoleSyncSettings={async (data) => {
|
||||
try {
|
||||
await patchRoleSyncSettingsMutation.mutateAsync(data);
|
||||
toast.success("IdP Role sync settings updated.");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"Failed to update IdP role sync settings.",
|
||||
),
|
||||
{
|
||||
description: getErrorDetail(error),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
onSubmitRoleSyncSettings={async (data) => {
|
||||
try {
|
||||
await patchRoleSyncSettingsMutation.mutateAsync(data);
|
||||
toast.success("IdP Role sync settings updated.");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"Failed to update IdP role sync settings.",
|
||||
),
|
||||
{
|
||||
description: getErrorDetail(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+161
-135
@@ -11,7 +11,6 @@ import type {
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -225,12 +224,6 @@ export const TemplatePermissionsPageView: FC<
|
||||
onUpdateGroup,
|
||||
onRemoveGroup,
|
||||
}) => {
|
||||
const isEmpty = Boolean(
|
||||
templateACL &&
|
||||
templateACL.users.length === 0 &&
|
||||
templateACL.group.length === 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader className="pt-0">
|
||||
@@ -259,137 +252,170 @@ export const TemplatePermissionsPageView: FC<
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={!templateACL}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No members yet"
|
||||
description="Add a member using the controls above"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
<Cond>
|
||||
{templateACL?.group.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
size="lg"
|
||||
fallback={group.display_name || group.name}
|
||||
src={group.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={group.display_name || group.name}
|
||||
subtitle={getGroupSubtitle(group)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChooseOne>
|
||||
<Cond condition={canUpdatePermissions}>
|
||||
<RoleSelect
|
||||
value={group.role}
|
||||
disabled={updatingGroupId === group.id}
|
||||
onValueChange={(role) => {
|
||||
onUpdateGroup(group, role);
|
||||
}}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<div className="capitalize">{group.role}</div>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{canUpdatePermissions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onRemoveGroup(group)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{templateACL?.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={user.username}
|
||||
subtitle={user.email}
|
||||
src={user.avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChooseOne>
|
||||
<Cond condition={canUpdatePermissions}>
|
||||
<RoleSelect
|
||||
value={user.role}
|
||||
disabled={updatingUserId === user.id}
|
||||
onValueChange={(role) => {
|
||||
onUpdateUser(user, role);
|
||||
}}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<div className="capitalize">{user.role}</div>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{canUpdatePermissions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onRemoveUser(user)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
<MembersTableBody
|
||||
templateACL={templateACL}
|
||||
canUpdatePermissions={canUpdatePermissions}
|
||||
updatingUserId={updatingUserId}
|
||||
updatingGroupId={updatingGroupId}
|
||||
onUpdateUser={onUpdateUser}
|
||||
onRemoveUser={onRemoveUser}
|
||||
onUpdateGroup={onUpdateGroup}
|
||||
onRemoveGroup={onRemoveGroup}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface MembersTableBodyProps {
|
||||
templateACL: TemplateACL | undefined;
|
||||
canUpdatePermissions: boolean;
|
||||
updatingUserId: TemplateUser["id"] | undefined;
|
||||
updatingGroupId: TemplateGroup["id"] | undefined;
|
||||
onUpdateUser: (user: TemplateUser, role: TemplateRole) => void;
|
||||
onRemoveUser: (user: TemplateUser) => void;
|
||||
onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void;
|
||||
onRemoveGroup: (group: Group) => void;
|
||||
}
|
||||
|
||||
const MembersTableBody: FC<MembersTableBodyProps> = ({
|
||||
templateACL,
|
||||
canUpdatePermissions,
|
||||
updatingUserId,
|
||||
updatingGroupId,
|
||||
onUpdateUser,
|
||||
onRemoveUser,
|
||||
onUpdateGroup,
|
||||
onRemoveGroup,
|
||||
}) => {
|
||||
if (!templateACL) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
templateACL.users.length === 0 && templateACL.group.length === 0;
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No members yet"
|
||||
description="Add a member using the controls above"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{templateACL.group.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
size="lg"
|
||||
fallback={group.display_name || group.name}
|
||||
src={group.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={group.display_name || group.name}
|
||||
subtitle={getGroupSubtitle(group)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canUpdatePermissions ? (
|
||||
<RoleSelect
|
||||
value={group.role}
|
||||
disabled={updatingGroupId === group.id}
|
||||
onValueChange={(role) => {
|
||||
onUpdateGroup(group, role);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="capitalize">{group.role}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{canUpdatePermissions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onRemoveGroup(group)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{templateACL.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={user.username}
|
||||
subtitle={user.email}
|
||||
src={user.avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canUpdatePermissions ? (
|
||||
<RoleSelect
|
||||
value={user.role}
|
||||
disabled={updatingUserId === user.id}
|
||||
onValueChange={(role) => {
|
||||
onUpdateUser(user, role);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="capitalize">{user.role}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{canUpdatePermissions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onRemoveUser(user)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { FC, ReactNode } from "react";
|
||||
import type { APIKeyWithOwner } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -44,8 +43,6 @@ export const TokensPageView: FC<TokensPageViewProps> = ({
|
||||
onDelete,
|
||||
deleteTokenError,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Boolean(getTokensError) && <ErrorAlert error={getTokensError} />}
|
||||
@@ -63,71 +60,88 @@ export const TokensPageView: FC<TokensPageViewProps> = ({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
<Cond condition={hasLoaded && (!tokens || tokens.length === 0)}>
|
||||
<TableEmpty message="No tokens found" />
|
||||
</Cond>
|
||||
<Cond>
|
||||
{tokens?.map((token) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={token.id}
|
||||
data-testid={`token-${token.id}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{token.id}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{token.token_name}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{lastUsedOrNever(token.last_used)}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span
|
||||
style={{ color: theme.palette.text.secondary }}
|
||||
data-chromatic="ignore"
|
||||
>
|
||||
{dayjs(token.expires_at).fromNow()}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{dayjs(token.created_at).fromNow()}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onDelete(token);
|
||||
}}
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
aria-label="Delete token"
|
||||
>
|
||||
<TrashIcon className="size-icon-sm" />
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
<TokensTableBody
|
||||
tokens={tokens}
|
||||
isLoading={isLoading}
|
||||
hasLoaded={hasLoaded}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TokensTableBodyProps {
|
||||
tokens?: APIKeyWithOwner[];
|
||||
isLoading: boolean;
|
||||
hasLoaded: boolean;
|
||||
onDelete: (token: APIKeyWithOwner) => void;
|
||||
}
|
||||
|
||||
const TokensTableBody: FC<TokensTableBodyProps> = ({
|
||||
tokens,
|
||||
isLoading,
|
||||
hasLoaded,
|
||||
onDelete,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
if (hasLoaded && (!tokens || tokens.length === 0)) {
|
||||
return <TableEmpty message="No tokens found" />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{tokens?.map((token) => (
|
||||
<TableRow key={token.id} data-testid={`token-${token.id}`} tabIndex={0}>
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{token.id}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{token.token_name}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{lastUsedOrNever(token.last_used)}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span
|
||||
style={{ color: theme.palette.text.secondary }}
|
||||
data-chromatic="ignore"
|
||||
>
|
||||
{dayjs(token.expires_at).fromNow()}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{dayjs(token.created_at).fromNow()}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onDelete(token);
|
||||
}}
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
aria-label="Delete token"
|
||||
>
|
||||
<TrashIcon className="size-icon-sm" />
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import type { Region } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
@@ -61,25 +60,46 @@ export const WorkspaceProxyView: FC<WorkspaceProxyViewProps> = ({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
<Cond condition={hasLoaded && proxies?.length === 0}>
|
||||
<TableEmpty message="No workspace proxies found" />
|
||||
</Cond>
|
||||
<Cond>
|
||||
{proxies?.map((proxy) => (
|
||||
<ProxyRow
|
||||
latency={proxyLatencies?.[proxy.id]}
|
||||
key={proxy.id}
|
||||
proxy={proxy}
|
||||
/>
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
<ProxiesTableBody
|
||||
proxies={proxies}
|
||||
proxyLatencies={proxyLatencies}
|
||||
isLoading={isLoading}
|
||||
hasLoaded={hasLoaded}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProxiesTableBodyProps {
|
||||
proxies?: readonly Region[];
|
||||
proxyLatencies?: Record<string, ProxyLatencyReport>;
|
||||
isLoading: boolean;
|
||||
hasLoaded: boolean;
|
||||
}
|
||||
|
||||
const ProxiesTableBody: FC<ProxiesTableBodyProps> = ({
|
||||
proxies,
|
||||
proxyLatencies,
|
||||
isLoading,
|
||||
hasLoaded,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <TableLoader />;
|
||||
}
|
||||
if (hasLoaded && proxies?.length === 0) {
|
||||
return <TableEmpty message="No workspace proxies found" />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{proxies?.map((proxy) => (
|
||||
<ProxyRow
|
||||
latency={proxyLatencies?.[proxy.id]}
|
||||
key={proxy.id}
|
||||
proxy={proxy}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user