mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor(site): migrate organization settings form off MUI (#27718)
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
Migrates the organization settings info form from MUI TextField to
shared FormField/Input/Textarea patterns, and splits the page view into
focused section components so info, workspace sharing, and delete are no
longer one monolithic file.
Behavior is unchanged; this is structure and component migration only.
- Replace MUI TextField with FormField for slug and display name, plus a
Textarea for description
- Extract OrganizationInfoForm, WorkspaceSharingSection, and
DeleteOrganizationSection
- Move sharing dialog Storybook coverage onto WorkspaceSharingSection
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog";
|
||||
import { FormSection, HorizontalForm } from "#/components/Form/Form";
|
||||
|
||||
type DeleteOrganizationSectionProps = {
|
||||
organizationName: string;
|
||||
onDeleteOrganization: () => void;
|
||||
};
|
||||
|
||||
export const DeleteOrganizationSection: FC<DeleteOrganizationSectionProps> = ({
|
||||
organizationName,
|
||||
onDeleteOrganization,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HorizontalForm className="mt-12">
|
||||
<FormSection
|
||||
title="Delete Organization"
|
||||
description="Delete your organization permanently."
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-grow">
|
||||
<div className="flex bg-surface-red items-center justify-between border border-solid border-border-destructive rounded-md p-3 pl-4 gap-2">
|
||||
<span>Deleting an organization is irreversible.</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleting(true)}
|
||||
className="min-w-fit"
|
||||
>
|
||||
Delete this organization
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</HorizontalForm>
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={isDeleting}
|
||||
onConfirm={async () => {
|
||||
await onDeleteOrganization();
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleting(false)}
|
||||
entity="organization"
|
||||
name={organizationName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import * as Yup from "yup";
|
||||
import { isApiValidationError } from "#/api/errors";
|
||||
import type {
|
||||
Organization,
|
||||
UpdateOrganizationRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
VerticalForm,
|
||||
} from "#/components/Form/Form";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { Textarea } from "#/components/Textarea/Textarea";
|
||||
import { cn } from "#/utils/cn";
|
||||
import {
|
||||
displayNameValidator,
|
||||
getFormHelpers,
|
||||
nameValidator,
|
||||
onChangeTrimmed,
|
||||
} from "#/utils/formUtils";
|
||||
|
||||
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
|
||||
const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`;
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("Name"),
|
||||
display_name: displayNameValidator("Display name"),
|
||||
description: Yup.string().max(
|
||||
MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
MAX_DESCRIPTION_MESSAGE,
|
||||
),
|
||||
});
|
||||
|
||||
type OrganizationInfoFormProps = {
|
||||
organization: Organization;
|
||||
error: unknown;
|
||||
onSubmit: (values: UpdateOrganizationRequest) => Promise<void>;
|
||||
};
|
||||
|
||||
export const OrganizationInfoForm: FC<OrganizationInfoFormProps> = ({
|
||||
organization,
|
||||
error,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const form = useFormik<UpdateOrganizationRequest>({
|
||||
initialValues: {
|
||||
name: organization.name,
|
||||
display_name: organization.display_name,
|
||||
description: organization.description,
|
||||
icon: organization.icon,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
enableReinitialize: true,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form, error);
|
||||
const descriptionField = getFieldHelpers("description", {
|
||||
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
});
|
||||
const descriptionErrorId = `${descriptionField.id}-error`;
|
||||
const descriptionHelperId = `${descriptionField.id}-helper`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Boolean(error) && !isApiValidationError(error) && (
|
||||
<div className="mb-8">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VerticalForm
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
>
|
||||
<FormSection
|
||||
title="Info"
|
||||
description="The name and description of the organization."
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="border-0 p-0 m-0 w-full"
|
||||
>
|
||||
<FormFields>
|
||||
<FormField
|
||||
field={getFieldHelpers("name")}
|
||||
label="Slug"
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
/>
|
||||
<FormField
|
||||
field={getFieldHelpers("display_name")}
|
||||
label="Display name"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={descriptionField.id}>Description</Label>
|
||||
<Textarea
|
||||
id={descriptionField.id}
|
||||
name={descriptionField.name}
|
||||
value={descriptionField.value}
|
||||
onChange={descriptionField.onChange}
|
||||
onBlur={descriptionField.onBlur}
|
||||
rows={2}
|
||||
aria-invalid={descriptionField.error}
|
||||
aria-describedby={
|
||||
descriptionField.error
|
||||
? descriptionErrorId
|
||||
: descriptionField.helperText
|
||||
? descriptionHelperId
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
descriptionField.error && "border-border-destructive",
|
||||
)}
|
||||
/>
|
||||
{descriptionField.error ? (
|
||||
<span
|
||||
id={descriptionErrorId}
|
||||
className="text-xs text-content-destructive"
|
||||
>
|
||||
{descriptionField.helperText}
|
||||
</span>
|
||||
) : (
|
||||
descriptionField.helperText && (
|
||||
<span
|
||||
id={descriptionHelperId}
|
||||
className="text-xs text-content-secondary"
|
||||
>
|
||||
{descriptionField.helperText}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
</fieldset>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter>
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</VerticalForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { action } from "storybook/actions";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization,
|
||||
@@ -12,6 +11,10 @@ const meta: Meta<typeof OrganizationSettingsPageView> = {
|
||||
component: OrganizationSettingsPageView,
|
||||
args: {
|
||||
organization: MockOrganization,
|
||||
onSubmit: action("onSubmit"),
|
||||
onDeleteOrganization: action("onDeleteOrganization"),
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,62 +28,3 @@ export const DefaultOrg: Story = {
|
||||
organization: MockDefaultOrganization,
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingDisabled: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "none",
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingServiceAccountsOnly: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "service_accounts",
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingEveryone: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingGloballyDisabled: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "none",
|
||||
workspaceSharingGloballyDisabled: true,
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableSharingDialog: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const checkbox = await body.findByRole("checkbox", {
|
||||
name: /allow workspace sharing/i,
|
||||
});
|
||||
await user.click(checkbox);
|
||||
},
|
||||
};
|
||||
|
||||
export const RestrictToServiceAccountsDialog: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const radio = await body.findByRole("radio", {
|
||||
name: /only service accounts/i,
|
||||
});
|
||||
await user.click(radio);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useFormik } from "formik";
|
||||
import { type FC, useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
import { isApiValidationError } from "#/api/errors";
|
||||
import type { FC } from "react";
|
||||
import type {
|
||||
Organization,
|
||||
ShareableWorkspaceOwners,
|
||||
UpdateOrganizationRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Alert, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Checkbox } from "#/components/Checkbox/Checkbox";
|
||||
import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog";
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
} from "#/components/Form/Form";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
displayNameValidator,
|
||||
getFormHelpers,
|
||||
nameValidator,
|
||||
onChangeTrimmed,
|
||||
} from "#/utils/formUtils";
|
||||
import { DisableWorkspaceSharingDialog } from "./DisableWorkspaceSharingDialog";
|
||||
|
||||
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
|
||||
const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`;
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("Name"),
|
||||
display_name: displayNameValidator("Display name"),
|
||||
description: Yup.string().max(
|
||||
MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
MAX_DESCRIPTION_MESSAGE,
|
||||
),
|
||||
});
|
||||
import { DeleteOrganizationSection } from "./DeleteOrganizationSection";
|
||||
import { OrganizationInfoForm } from "./OrganizationInfoForm";
|
||||
import { WorkspaceSharingSection } from "./WorkspaceSharingSection";
|
||||
|
||||
interface OrganizationSettingsPageViewProps {
|
||||
organization: Organization;
|
||||
@@ -52,9 +18,9 @@ interface OrganizationSettingsPageViewProps {
|
||||
onSubmit: (values: UpdateOrganizationRequest) => Promise<void>;
|
||||
onDeleteOrganization: () => void;
|
||||
workspaceSharingGloballyDisabled?: boolean;
|
||||
shareableWorkspaceOwners: ShareableWorkspaceOwners;
|
||||
onChangeShareableOwners: (value: ShareableWorkspaceOwners) => void;
|
||||
isTogglingWorkspaceSharing: boolean;
|
||||
shareableWorkspaceOwners?: ShareableWorkspaceOwners;
|
||||
onChangeShareableOwners?: (value: ShareableWorkspaceOwners) => void;
|
||||
isTogglingWorkspaceSharing?: boolean;
|
||||
}
|
||||
|
||||
export const OrganizationSettingsPageView: FC<
|
||||
@@ -65,245 +31,38 @@ export const OrganizationSettingsPageView: FC<
|
||||
onSubmit,
|
||||
onDeleteOrganization,
|
||||
workspaceSharingGloballyDisabled,
|
||||
shareableWorkspaceOwners,
|
||||
shareableWorkspaceOwners = "none",
|
||||
onChangeShareableOwners,
|
||||
isTogglingWorkspaceSharing,
|
||||
isTogglingWorkspaceSharing = false,
|
||||
}) => {
|
||||
const form = useFormik<UpdateOrganizationRequest>({
|
||||
initialValues: {
|
||||
name: organization.name,
|
||||
display_name: organization.display_name,
|
||||
description: organization.description,
|
||||
icon: organization.icon,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
enableReinitialize: true,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form, error);
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [pendingSharingChange, setPendingSharingChange] =
|
||||
useState<ShareableWorkspaceOwners | null>(null);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-2xl pb-10">
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>Settings</SettingsHeaderTitle>
|
||||
</SettingsHeader>
|
||||
|
||||
{Boolean(error) && !isApiValidationError(error) && (
|
||||
<div className="mb-8">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HorizontalForm
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
>
|
||||
<FormSection
|
||||
title="Info"
|
||||
description="The name and description of the organization."
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="border-0 p-0 m-0 w-full"
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Slug"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
fullWidth
|
||||
label="Display name"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
multiline
|
||||
fullWidth
|
||||
label="Description"
|
||||
rows={2}
|
||||
/>
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
</fieldset>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter>
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</HorizontalForm>
|
||||
<OrganizationInfoForm
|
||||
organization={organization}
|
||||
error={error}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
{onChangeShareableOwners && (
|
||||
<HorizontalForm className="mt-12">
|
||||
<FormSection
|
||||
title="Workspace Sharing"
|
||||
description="Control whether workspace owners can share their workspaces."
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{workspaceSharingGloballyDisabled && (
|
||||
<Alert severity="warning" className="mb-4">
|
||||
<AlertTitle>Disabled by deployment settings</AlertTitle>
|
||||
Workspace sharing has been disallowed by an administrator.
|
||||
Sharing must be allowed by an administrator before sharing can
|
||||
be used in this organization.
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="workspace-sharing"
|
||||
checked={
|
||||
!workspaceSharingGloballyDisabled &&
|
||||
shareableWorkspaceOwners !== "none"
|
||||
}
|
||||
disabled={
|
||||
workspaceSharingGloballyDisabled ||
|
||||
isTogglingWorkspaceSharing
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
// Default to service_accounts when enabling.
|
||||
onChangeShareableOwners("service_accounts");
|
||||
} else {
|
||||
setPendingSharingChange("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
htmlFor="workspace-sharing"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Allow workspace sharing
|
||||
</label>
|
||||
<div className="text-xs text-content-secondary">
|
||||
When enabled, workspace owners can share their workspaces
|
||||
with other users in this organization.
|
||||
</div>
|
||||
</div>
|
||||
{shareableWorkspaceOwners !== "none" &&
|
||||
!workspaceSharingGloballyDisabled && (
|
||||
<RadioGroup
|
||||
value={shareableWorkspaceOwners}
|
||||
onValueChange={(value) => {
|
||||
const newValue = value as ShareableWorkspaceOwners;
|
||||
// Transitioning from "everyone" to "service_accounts"
|
||||
// is destructive, so show the warning dialog.
|
||||
// Otherwise, just change.
|
||||
if (
|
||||
shareableWorkspaceOwners === "everyone" &&
|
||||
newValue === "service_accounts"
|
||||
) {
|
||||
setPendingSharingChange("service_accounts");
|
||||
} else {
|
||||
onChangeShareableOwners(newValue);
|
||||
}
|
||||
}}
|
||||
disabled={isTogglingWorkspaceSharing}
|
||||
className="ml-1"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
value="service_accounts"
|
||||
id="sharing-service-accounts"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
htmlFor="sharing-service-accounts"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Only service accounts can share workspaces
|
||||
</label>
|
||||
<span className="text-xs text-content-secondary">
|
||||
Service accounts are non-login accounts typically
|
||||
used for automation, CI/CD pipelines, and
|
||||
centrally-managed shared environments.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value="everyone"
|
||||
id="sharing-everyone"
|
||||
/>
|
||||
<label
|
||||
htmlFor="sharing-everyone"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
All members can share workspaces
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</HorizontalForm>
|
||||
<WorkspaceSharingSection
|
||||
organizationId={organization.id}
|
||||
workspaceSharingGloballyDisabled={workspaceSharingGloballyDisabled}
|
||||
shareableWorkspaceOwners={shareableWorkspaceOwners}
|
||||
onChangeShareableOwners={onChangeShareableOwners}
|
||||
isTogglingWorkspaceSharing={isTogglingWorkspaceSharing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!organization.is_default && (
|
||||
<HorizontalForm className="mt-12">
|
||||
<FormSection
|
||||
title="Delete Organization"
|
||||
description="Delete your organization permanently."
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-grow">
|
||||
<div className="flex bg-surface-orange items-center justify-between border border-solid border-orange-600 rounded-md p-3 pl-4 gap-2">
|
||||
<span>Deleting an organization is irreversible.</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleting(true)}
|
||||
className="min-w-fit"
|
||||
>
|
||||
Delete this organization
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</HorizontalForm>
|
||||
<DeleteOrganizationSection
|
||||
organizationName={organization.name}
|
||||
onDeleteOrganization={onDeleteOrganization}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={isDeleting}
|
||||
onConfirm={async () => {
|
||||
await onDeleteOrganization();
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleting(false)}
|
||||
entity="organization"
|
||||
name={organization.name}
|
||||
/>
|
||||
|
||||
<DisableWorkspaceSharingDialog
|
||||
isOpen={pendingSharingChange !== null}
|
||||
organizationId={organization.id}
|
||||
newSetting={pendingSharingChange ?? "none"}
|
||||
onConfirm={async () => {
|
||||
if (pendingSharingChange !== null) {
|
||||
await onChangeShareableOwners(pendingSharingChange);
|
||||
}
|
||||
setPendingSharingChange(null);
|
||||
}}
|
||||
onCancel={() => setPendingSharingChange(null)}
|
||||
isLoading={isTogglingWorkspaceSharing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { action } from "storybook/actions";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { MockOrganization } from "#/testHelpers/entities";
|
||||
import { WorkspaceSharingSection } from "./WorkspaceSharingSection";
|
||||
|
||||
const meta: Meta<typeof WorkspaceSharingSection> = {
|
||||
title: "pages/OrganizationSettingsPage/WorkspaceSharingSection",
|
||||
component: WorkspaceSharingSection,
|
||||
args: {
|
||||
organizationId: MockOrganization.id,
|
||||
onChangeShareableOwners: action("onChangeShareableOwners"),
|
||||
isTogglingWorkspaceSharing: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspaceSharingSection>;
|
||||
|
||||
export const SharingDisabled: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingServiceAccountsOnly: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "service_accounts",
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingEveryone: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
},
|
||||
};
|
||||
|
||||
export const SharingGloballyDisabled: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "none",
|
||||
workspaceSharingGloballyDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableSharingDialog: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const checkbox = await body.findByRole("checkbox", {
|
||||
name: /allow workspace sharing/i,
|
||||
});
|
||||
await user.click(checkbox);
|
||||
},
|
||||
};
|
||||
|
||||
export const RestrictToServiceAccountsDialog: Story = {
|
||||
args: {
|
||||
shareableWorkspaceOwners: "everyone",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const radio = await body.findByRole("radio", {
|
||||
name: /only service accounts/i,
|
||||
});
|
||||
await user.click(radio);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { type FC, useId, useState } from "react";
|
||||
import {
|
||||
type ShareableWorkspaceOwners,
|
||||
ShareableWorkspaceOwnerses,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Alert, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Checkbox } from "#/components/Checkbox/Checkbox";
|
||||
import { FormSection, HorizontalForm } from "#/components/Form/Form";
|
||||
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
|
||||
import { DisableWorkspaceSharingDialog } from "./DisableWorkspaceSharingDialog";
|
||||
|
||||
const isShareableWorkspaceOwners = (
|
||||
value: string,
|
||||
): value is ShareableWorkspaceOwners =>
|
||||
ShareableWorkspaceOwnerses.some((option) => option === value);
|
||||
|
||||
type WorkspaceSharingSectionProps = {
|
||||
organizationId: string;
|
||||
workspaceSharingGloballyDisabled?: boolean;
|
||||
shareableWorkspaceOwners: ShareableWorkspaceOwners;
|
||||
onChangeShareableOwners: (value: ShareableWorkspaceOwners) => void;
|
||||
isTogglingWorkspaceSharing: boolean;
|
||||
};
|
||||
|
||||
export const WorkspaceSharingSection: FC<WorkspaceSharingSectionProps> = ({
|
||||
organizationId,
|
||||
workspaceSharingGloballyDisabled,
|
||||
shareableWorkspaceOwners,
|
||||
onChangeShareableOwners,
|
||||
isTogglingWorkspaceSharing,
|
||||
}) => {
|
||||
const [pendingSharingChange, setPendingSharingChange] =
|
||||
useState<ShareableWorkspaceOwners | null>(null);
|
||||
|
||||
const id = useId();
|
||||
const workspaceSharingId = `${id}-workspace-sharing`;
|
||||
const sharingServiceAccountsId = `${id}-sharing-service-accounts`;
|
||||
const sharingEveryoneId = `${id}-sharing-everyone`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HorizontalForm className="mt-12">
|
||||
<FormSection
|
||||
title="Workspace Sharing"
|
||||
description="Control whether workspace owners can share their workspaces."
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{workspaceSharingGloballyDisabled && (
|
||||
<Alert severity="warning" className="mb-4">
|
||||
<AlertTitle>Disabled by deployment settings</AlertTitle>
|
||||
Workspace sharing has been disallowed by an administrator.
|
||||
Sharing must be allowed by an administrator before sharing can
|
||||
be used in this organization.
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={workspaceSharingId}
|
||||
checked={
|
||||
!workspaceSharingGloballyDisabled &&
|
||||
shareableWorkspaceOwners !== "none"
|
||||
}
|
||||
disabled={
|
||||
workspaceSharingGloballyDisabled || isTogglingWorkspaceSharing
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChangeShareableOwners("service_accounts");
|
||||
} else {
|
||||
setPendingSharingChange("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
htmlFor={workspaceSharingId}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Allow workspace sharing
|
||||
</label>
|
||||
<div className="text-xs text-content-secondary">
|
||||
When enabled, workspace owners can share their workspaces
|
||||
with other users in this organization.
|
||||
</div>
|
||||
</div>
|
||||
{shareableWorkspaceOwners !== "none" &&
|
||||
!workspaceSharingGloballyDisabled && (
|
||||
<RadioGroup
|
||||
value={shareableWorkspaceOwners}
|
||||
onValueChange={(value) => {
|
||||
if (!isShareableWorkspaceOwners(value)) {
|
||||
return;
|
||||
}
|
||||
// Restricting from everyone to service accounts
|
||||
// revokes existing shares, so confirm first.
|
||||
if (
|
||||
shareableWorkspaceOwners === "everyone" &&
|
||||
value === "service_accounts"
|
||||
) {
|
||||
setPendingSharingChange("service_accounts");
|
||||
} else {
|
||||
onChangeShareableOwners(value);
|
||||
}
|
||||
}}
|
||||
disabled={isTogglingWorkspaceSharing}
|
||||
className="ml-1"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
value="service_accounts"
|
||||
id={sharingServiceAccountsId}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
htmlFor={sharingServiceAccountsId}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Only service accounts can share workspaces
|
||||
</label>
|
||||
<span className="text-xs text-content-secondary">
|
||||
Service accounts are non-login accounts typically
|
||||
used for automation, CI/CD pipelines, and
|
||||
centrally-managed shared environments.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value="everyone"
|
||||
id={sharingEveryoneId}
|
||||
/>
|
||||
<label
|
||||
htmlFor={sharingEveryoneId}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
All members can share workspaces
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</HorizontalForm>
|
||||
|
||||
<DisableWorkspaceSharingDialog
|
||||
isOpen={pendingSharingChange !== null}
|
||||
organizationId={organizationId}
|
||||
newSetting={pendingSharingChange ?? "none"}
|
||||
onConfirm={async () => {
|
||||
if (pendingSharingChange !== null) {
|
||||
await onChangeShareableOwners(pendingSharingChange);
|
||||
}
|
||||
setPendingSharingChange(null);
|
||||
}}
|
||||
onCancel={() => setPendingSharingChange(null)}
|
||||
isLoading={isTogglingWorkspaceSharing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user