mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
feat(site): gate appearance settings behind Premium paywall (#27948)
Appearance settings previously showed a Premium paywall badge above still-visible branding and announcement banner forms. When appearance is not entitled, show only the shared Premium paywall. When entitled, show the branding form and announcement banners.
This commit is contained in:
+1
-4
@@ -9,7 +9,6 @@ import {
|
||||
import type { UpdateAppearanceConfig } from "#/api/typesGenerated";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView";
|
||||
@@ -20,7 +19,6 @@ import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView";
|
||||
// the command line would be a significantly worse user experience.
|
||||
const AppearanceSettingsPage: FC = () => {
|
||||
const { appearance, entitlements } = useDashboard();
|
||||
const { multiple_organizations: hasPremiumLicense } = useFeatureVisibility();
|
||||
const queryClient = useQueryClient();
|
||||
const updateAppearanceMutation = useMutation(updateAppearance(queryClient));
|
||||
const { permissions } = useAuthenticated();
|
||||
@@ -61,8 +59,7 @@ const AppearanceSettingsPage: FC = () => {
|
||||
isEntitled={
|
||||
entitlements.features.appearance.entitlement !== "not_entitled"
|
||||
}
|
||||
isPremium={hasPremiumLicense}
|
||||
permissions={permissions}
|
||||
canViewPremium={permissions.viewAllLicenses}
|
||||
/>
|
||||
</RequirePermission>
|
||||
</>
|
||||
|
||||
+21
-9
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, screen, userEvent, within } from "storybook/test";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { MockPermissions } from "#/testHelpers/entities";
|
||||
import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView";
|
||||
|
||||
@@ -24,7 +24,7 @@ const meta: Meta<typeof AppearanceSettingsPageView> = {
|
||||
],
|
||||
},
|
||||
isEntitled: false,
|
||||
permissions: MockPermissions,
|
||||
canViewPremium: MockPermissions.viewAllLicenses,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,11 +35,17 @@ export const Entitled: Story = {
|
||||
args: {
|
||||
isEntitled: true,
|
||||
},
|
||||
play: async () => {
|
||||
// The badge is passive: hovering it must not surface a paywall.
|
||||
await userEvent.hover(screen.getByText("Enterprise"));
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
screen.queryByRole("link", { name: "Learn more about premium" }),
|
||||
canvas.getByRole("form", { name: "Appearance settings" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: "Announcement Banners" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.queryByRole("link", { name: "Learn about Premium" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -48,14 +54,20 @@ export const NotEntitled: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const cta = canvas.getByRole("link", { name: "Start trial for free" });
|
||||
const cta = canvas.getByRole("link", { name: "Learn about Premium" });
|
||||
await expect(cta).toHaveAttribute("href", "/deployment/premium");
|
||||
await expect(
|
||||
canvas.queryByRole("form", { name: "Appearance settings" }),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByRole("heading", { name: "Announcement Banners" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const NotEntitledWithoutLicenseAccess: Story = {
|
||||
args: {
|
||||
permissions: { ...MockPermissions, viewAllLicenses: false },
|
||||
canViewPremium: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -64,7 +76,7 @@ export const NotEntitledWithoutLicenseAccess: Story = {
|
||||
canvas.getByText(/contact your deployment administrator/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.queryByRole("link", { name: "Start trial for free" }),
|
||||
canvas.queryByRole("link", { name: "Learn about Premium" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
+64
-86
@@ -1,11 +1,6 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import type { UpdateAppearanceConfig } from "#/api/typesGenerated";
|
||||
import {
|
||||
Badges,
|
||||
EnterpriseBadge,
|
||||
PremiumBadge,
|
||||
} from "#/components/Badges/Badges";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
FormFields,
|
||||
@@ -15,22 +10,20 @@ import {
|
||||
} from "#/components/Form/Form";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { PaywallSmall } from "#/components/Paywall/PaywallSmall";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import type { Permissions } from "#/modules/permissions";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
import { AnnouncementBannerSettings } from "./AnnouncementBannerSettings";
|
||||
|
||||
type AppearanceSettingsPageViewProps = {
|
||||
appearance: UpdateAppearanceConfig;
|
||||
isEntitled: boolean;
|
||||
isPremium: boolean;
|
||||
permissions: Permissions;
|
||||
canViewPremium: boolean;
|
||||
onSaveAppearance: (
|
||||
newConfig: Partial<UpdateAppearanceConfig>,
|
||||
) => Promise<void>;
|
||||
@@ -38,7 +31,7 @@ type AppearanceSettingsPageViewProps = {
|
||||
|
||||
export const AppearanceSettingsPageView: FC<
|
||||
AppearanceSettingsPageViewProps
|
||||
> = ({ appearance, isEntitled, isPremium, permissions, onSaveAppearance }) => {
|
||||
> = ({ appearance, isEntitled, canViewPremium, onSaveAppearance }) => {
|
||||
const form = useFormik<{
|
||||
application_name: string;
|
||||
logo_url: string;
|
||||
@@ -51,89 +44,74 @@ export const AppearanceSettingsPageView: FC<
|
||||
enableReinitialize: true,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
const fieldsDisabled = !isEntitled || form.isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div>
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>Appearance</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Customize the look and feel of your Coder deployment.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
<div className="flex flex-col gap-8">
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>Appearance</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Customize the look and feel of your Coder deployment.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
<Badges>
|
||||
{isEntitled ? (
|
||||
isPremium ? (
|
||||
<PremiumBadge />
|
||||
) : (
|
||||
<EnterpriseBadge />
|
||||
)
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<PaywallSmall
|
||||
message="Appearance"
|
||||
description="With a Premium license, you can customize the appearance and branding of your deployment."
|
||||
canViewPremium={permissions.viewAllLicenses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Badges>
|
||||
|
||||
<VerticalForm
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Appearance settings"
|
||||
className="mt-8"
|
||||
>
|
||||
<FormSection
|
||||
title="Branding"
|
||||
description="Customize the application name and logo shown on the login page and in the dashboard."
|
||||
{!isEntitled ? (
|
||||
<PaywallPremium
|
||||
message="Appearance"
|
||||
description="With a Premium license, you can customize branding and announcement banners for your deployment."
|
||||
canViewPremium={canViewPremium}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<VerticalForm
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Appearance settings"
|
||||
>
|
||||
<FormFields>
|
||||
<FormField
|
||||
field={getFieldHelpers("application_name", {
|
||||
helperText: isEntitled
|
||||
? 'Leave empty to use "Coder".'
|
||||
: "This is an Enterprise only feature.",
|
||||
})}
|
||||
label="Application name"
|
||||
placeholder="Coder"
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
<FormSection
|
||||
title="Branding"
|
||||
description="Customize the application name and logo shown on the login page and in the dashboard."
|
||||
>
|
||||
<FormFields>
|
||||
<FormField
|
||||
field={getFieldHelpers("application_name", {
|
||||
helperText: 'Leave empty to use "Coder".',
|
||||
})}
|
||||
label="Application name"
|
||||
placeholder="Coder"
|
||||
disabled={form.isSubmitting}
|
||||
/>
|
||||
|
||||
<IconField
|
||||
{...getFieldHelpers("logo_url", {
|
||||
helperText: isEntitled
|
||||
? "Leave empty to use the Coder logo. An image with transparency and an aspect ratio of 3:1 or less will look best."
|
||||
: "This is an Enterprise only feature.",
|
||||
})}
|
||||
label="Logo URL"
|
||||
placeholder="/icon/coder.svg"
|
||||
disabled={fieldsDisabled}
|
||||
onPickEmoji={(value) => {
|
||||
void form.setFieldValue("logo_url", value);
|
||||
}}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
<IconField
|
||||
{...getFieldHelpers("logo_url", {
|
||||
helperText:
|
||||
"Leave empty to use the Coder logo. An image with transparency and an aspect ratio of 3:1 or less will look best.",
|
||||
})}
|
||||
label="Logo URL"
|
||||
placeholder="/icon/coder.svg"
|
||||
disabled={form.isSubmitting}
|
||||
onPickEmoji={(value) => {
|
||||
void form.setFieldValue("logo_url", value);
|
||||
}}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter>
|
||||
<Button type="submit" disabled={fieldsDisabled}>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</VerticalForm>
|
||||
</div>
|
||||
<FormFooter>
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</VerticalForm>
|
||||
|
||||
<AnnouncementBannerSettings
|
||||
isEntitled={isEntitled}
|
||||
announcementBanners={appearance.announcement_banners || []}
|
||||
onSubmit={(announcementBanners) =>
|
||||
onSaveAppearance({ announcement_banners: announcementBanners })
|
||||
}
|
||||
/>
|
||||
<AnnouncementBannerSettings
|
||||
isEntitled
|
||||
announcementBanners={appearance.announcement_banners || []}
|
||||
onSubmit={(announcementBanners) =>
|
||||
onSaveAppearance({ announcement_banners: announcementBanners })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user