mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor(site): redesign <AppearanceSettingsPage /> (#27722)
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. - Redesign Deployment Appearance settings to the standard two-column settings layout (`HorizontalForm` / `FormSection`) for branding, with announcement banners as a secondary `SettingsHeader` + table section (aligned with OAuth2 apps / AI Governance). - Demui `IconField` and reuse it for Logo URL (URL input, preview, emoji picker); thin `IconPickerField` to wrap the shared component. - Clean up announcement banner create/edit (draft-only until save, page-top preview via portal, shadcn `Switch` / menu icons, empty-state CTA) and clarify Premium paywall copy to cover branding and announcement banners. | Old | New | | --- | --- | | <img width="2936" height="2666" alt="appearancesettings_old" src="https://github.com/user-attachments/assets/db11950d-2440-412b-a74d-fcf57f2467e9"/> | <img width="2936" height="2714" alt="appearancesettings_new" src="https://github.com/user-attachments/assets/3f418fb1-7f94-4fd8-bf54-0d63014c6533"/> |
This commit is contained in:
+53
-20
@@ -1,5 +1,4 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useFormik } from "formik";
|
||||
import { type FC, useState } from "react";
|
||||
import { SliderPicker, TwitterPicker } from "react-color";
|
||||
@@ -13,7 +12,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Textarea } from "#/components/Textarea/Textarea";
|
||||
import { AnnouncementBannerView } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBannerView";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
|
||||
interface AnnouncementBannerDialogProps {
|
||||
@@ -28,6 +30,7 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
|
||||
onUpdate,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isCreating = banner.message === "";
|
||||
|
||||
const bannerForm = useFormik<{
|
||||
message: string;
|
||||
@@ -39,9 +42,15 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
|
||||
},
|
||||
onSubmit: (banner) => onUpdate(banner),
|
||||
});
|
||||
const bannerFieldHelpers = getFormHelpers(bannerForm);
|
||||
const getFieldHelpers = getFormHelpers(bannerForm);
|
||||
const messageField = getFieldHelpers("message", {
|
||||
helperText: "Markdown bold, italics, and links are supported.",
|
||||
});
|
||||
const messageHelperId = `${messageField.id}-helper`;
|
||||
const messageErrorId = `${messageField.id}-error`;
|
||||
|
||||
const [showHuePicker, setShowHuePicker] = useState(false);
|
||||
const previewMessage = bannerForm.values.message.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -52,8 +61,10 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Banner preview */}
|
||||
<div className="fixed top-0 left-0 right-0 z-[60]">
|
||||
{/* Banner preview. Rendered outside DialogContent so its fixed
|
||||
positioning is relative to the viewport, not the dialog's
|
||||
transformed containing block. */}
|
||||
<div className="pointer-events-none fixed top-0 right-0 left-0 z-[60]">
|
||||
<AnnouncementBannerView
|
||||
message={bannerForm.values.message}
|
||||
backgroundColor={bannerForm.values.background_color}
|
||||
@@ -70,21 +81,43 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h4 className="m-0 mb-2 text-base font-semibold text-content-primary">
|
||||
Message
|
||||
</h4>
|
||||
<TextField
|
||||
{...bannerFieldHelpers("message", {
|
||||
helperText: "Markdown bold, italics, and links are supported.",
|
||||
})}
|
||||
fullWidth
|
||||
multiline
|
||||
inputProps={{
|
||||
"aria-label": "Message",
|
||||
placeholder: "Enter a message for the banner",
|
||||
}}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={messageField.id}>Message</Label>
|
||||
<Textarea
|
||||
id={messageField.id}
|
||||
name={messageField.name}
|
||||
value={messageField.value}
|
||||
onChange={messageField.onChange}
|
||||
onBlur={messageField.onBlur}
|
||||
rows={3}
|
||||
placeholder="Enter a message for the banner"
|
||||
aria-invalid={messageField.error}
|
||||
aria-describedby={
|
||||
messageField.error
|
||||
? messageErrorId
|
||||
: messageField.helperText
|
||||
? messageHelperId
|
||||
: undefined
|
||||
}
|
||||
className={cn(messageField.error && "border-border-destructive")}
|
||||
/>
|
||||
{messageField.error ? (
|
||||
<span
|
||||
id={messageErrorId}
|
||||
className="text-xs text-content-destructive"
|
||||
>
|
||||
{messageField.helperText}
|
||||
</span>
|
||||
) : (
|
||||
messageField.helperText && (
|
||||
<span
|
||||
id={messageHelperId}
|
||||
className="text-xs text-content-secondary"
|
||||
>
|
||||
{messageField.helperText}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="m-0 mb-2 text-base font-semibold text-content-primary">
|
||||
@@ -158,8 +191,8 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
|
||||
<DialogActions
|
||||
cancelText="Cancel"
|
||||
confirmLoading={bannerForm.isSubmitting}
|
||||
confirmText="Update"
|
||||
confirmDisabled={bannerForm.isSubmitting}
|
||||
confirmText={isCreating ? "Create" : "Update"}
|
||||
confirmDisabled={bannerForm.isSubmitting || previewMessage === ""}
|
||||
onCancel={onCancel}
|
||||
onConfirm={bannerForm.handleSubmit}
|
||||
/>
|
||||
|
||||
+10
-6
@@ -1,5 +1,4 @@
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { EllipsisVerticalIcon } from "lucide-react";
|
||||
import { EllipsisVerticalIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { BannerConfig } from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { TableCell, TableRow } from "#/components/Table/Table";
|
||||
|
||||
interface AnnouncementBannerItemProps {
|
||||
@@ -30,11 +30,13 @@ export const AnnouncementBannerItem: FC<AnnouncementBannerItemProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
size="small"
|
||||
<TableCell className="align-middle pl-5">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onClick={() => void onUpdate({ enabled: !enabled })}
|
||||
aria-label="Enabled"
|
||||
onCheckedChange={(checked) => {
|
||||
void onUpdate({ enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -56,12 +58,14 @@ export const AnnouncementBannerItem: FC<AnnouncementBannerItemProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit()}>
|
||||
<PencilIcon className="size-icon-xs" />
|
||||
Edit…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<TrashIcon className="size-icon-xs" />
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
+102
-80
@@ -4,6 +4,11 @@ import type { BannerConfig } from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,26 +20,46 @@ import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { AnnouncementBannerDialog } from "./AnnouncementBannerDialog";
|
||||
import { AnnouncementBannerItem } from "./AnnouncementBannerItem";
|
||||
|
||||
const DEFAULT_BANNER: BannerConfig = {
|
||||
enabled: true,
|
||||
message: "",
|
||||
background_color: "#ABB8C3",
|
||||
};
|
||||
|
||||
type NewBannerButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const NewBannerButton: FC<NewBannerButtonProps> = ({ onClick }) => (
|
||||
<Button onClick={onClick} variant="outline">
|
||||
<PlusIcon />
|
||||
New announcement
|
||||
</Button>
|
||||
);
|
||||
|
||||
interface AnnouncementBannersettingsProps {
|
||||
isEntitled: boolean;
|
||||
announcementBanners: readonly BannerConfig[];
|
||||
onSubmit: (banners: readonly BannerConfig[]) => Promise<void>;
|
||||
}
|
||||
|
||||
type EditingBanner = {
|
||||
/** `null` means creating a new banner. */
|
||||
index: number | null;
|
||||
banner: BannerConfig;
|
||||
};
|
||||
|
||||
export const AnnouncementBannerSettings: FC<
|
||||
AnnouncementBannersettingsProps
|
||||
> = ({ isEntitled, announcementBanners, onSubmit }) => {
|
||||
const [banners, setBanners] = useState(announcementBanners);
|
||||
const [editingBannerId, setEditingBannerId] = useState<number | null>(null);
|
||||
const [editingBanner, setEditingBanner] = useState<EditingBanner | null>(
|
||||
null,
|
||||
);
|
||||
const [deletingBannerId, setDeletingBannerId] = useState<number | null>(null);
|
||||
|
||||
const addBanner = () => {
|
||||
setBanners([
|
||||
...banners,
|
||||
{ enabled: true, message: "", background_color: "#ABB8C3" },
|
||||
]);
|
||||
setEditingBannerId(banners.length);
|
||||
};
|
||||
const openCreateDialog = () =>
|
||||
setEditingBanner({ index: null, banner: DEFAULT_BANNER });
|
||||
|
||||
const updateBanner = (i: number, banner: Partial<BannerConfig>) => {
|
||||
const newBanners = [...banners];
|
||||
@@ -50,93 +75,90 @@ export const AnnouncementBannerSettings: FC<
|
||||
return newBanners;
|
||||
};
|
||||
|
||||
const editingBanner = editingBannerId !== null && banners[editingBannerId];
|
||||
const deletingBanner = deletingBannerId !== null && banners[deletingBannerId];
|
||||
|
||||
// If we're not editing a new banner, remove all empty banners. This makes canceling the
|
||||
// "new" dialog more intuitive, by not persisting an empty banner.
|
||||
if (editingBannerId === null && banners.some((banner) => !banner.message)) {
|
||||
setBanners(banners.filter((banner) => banner.message));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 overflow-hidden rounded-lg border border-solid border-border">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="m-0 text-xl font-semibold">Announcement Banners</h3>
|
||||
<Button
|
||||
disabled={!isEntitled}
|
||||
onClick={() => addBanner()}
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-content-secondary">
|
||||
<div>
|
||||
<SettingsHeader
|
||||
actions={
|
||||
isEntitled ? (
|
||||
<NewBannerButton onClick={openCreateDialog} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SettingsHeaderTitle hierarchy="secondary" level="h2">
|
||||
Announcement Banners
|
||||
</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Display message banners to all users.
|
||||
</div>
|
||||
|
||||
<div className="pt-4 text-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[1%]">Enabled</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead className="w-[2%]">Color</TableHead>
|
||||
<TableHead className="w-[1%]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!isEntitled || banners.length < 1 ? (
|
||||
<TableEmpty
|
||||
className="min-h-[160px]"
|
||||
message="No announcement banners"
|
||||
/>
|
||||
) : (
|
||||
banners.map((banner, i) => (
|
||||
<AnnouncementBannerItem
|
||||
key={banner.message}
|
||||
enabled={banner.enabled && Boolean(banner.message)}
|
||||
backgroundColor={banner.background_color}
|
||||
message={banner.message}
|
||||
onEdit={() => setEditingBannerId(i)}
|
||||
onUpdate={async (banner) => {
|
||||
const newBanners = updateBanner(i, banner);
|
||||
await onSubmit(newBanners);
|
||||
}}
|
||||
onDelete={() => setDeletingBannerId(i)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEntitled && (
|
||||
<footer className="bg-surface-secondary px-6 py-4 text-sm">
|
||||
<div className="text-content-secondary">
|
||||
<p>
|
||||
{!isEntitled && (
|
||||
<>
|
||||
{" "}
|
||||
Your license does not include Service Banners.{" "}
|
||||
<Link href="mailto:sales@coder.com" showExternalIcon={false}>
|
||||
Contact sales
|
||||
</Link>{" "}
|
||||
to learn more.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
<Table aria-label="Announcement banners">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[1%] pl-5">Enabled</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead className="w-[2%]">Color</TableHead>
|
||||
<TableHead className="w-[1%]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!isEntitled || banners.length < 1 ? (
|
||||
<TableEmpty
|
||||
message="No announcement banners"
|
||||
description="Create a banner to display a message to all users."
|
||||
cta={
|
||||
isEntitled ? (
|
||||
<NewBannerButton onClick={openCreateDialog} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
banners.map((banner, i) => (
|
||||
<AnnouncementBannerItem
|
||||
key={banner.message}
|
||||
enabled={banner.enabled && Boolean(banner.message)}
|
||||
backgroundColor={banner.background_color}
|
||||
message={banner.message}
|
||||
onEdit={() => setEditingBanner({ index: i, banner })}
|
||||
onUpdate={async (banner) => {
|
||||
const newBanners = updateBanner(i, banner);
|
||||
await onSubmit(newBanners);
|
||||
}}
|
||||
onDelete={() => setDeletingBannerId(i)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{editingBanner && (
|
||||
<AnnouncementBannerDialog
|
||||
banner={editingBanner}
|
||||
onCancel={() => setEditingBannerId(null)}
|
||||
banner={editingBanner.banner}
|
||||
onCancel={() => setEditingBanner(null)}
|
||||
onUpdate={async (banner) => {
|
||||
const newBanners = updateBanner(editingBannerId, banner);
|
||||
setEditingBannerId(null);
|
||||
const nextBanner = { ...editingBanner.banner, ...banner };
|
||||
const newBanners =
|
||||
editingBanner.index === null
|
||||
? [...banners, nextBanner]
|
||||
: banners.map((existing, i) =>
|
||||
i === editingBanner.index ? nextBanner : existing,
|
||||
);
|
||||
setBanners(newBanners);
|
||||
setEditingBanner(null);
|
||||
await onSubmit(newBanners);
|
||||
}}
|
||||
/>
|
||||
|
||||
+82
-90
@@ -7,22 +7,24 @@ import {
|
||||
PremiumBadge,
|
||||
} from "#/components/Badges/Badges";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
VerticalForm,
|
||||
} from "#/components/Form/Form";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
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 { docs } from "#/utils/docs";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
import { Fieldset } from "../Fieldset";
|
||||
import { AnnouncementBannerSettings } from "./AnnouncementBannerSettings";
|
||||
|
||||
type AppearanceSettingsPageViewProps = {
|
||||
@@ -38,102 +40,92 @@ type AppearanceSettingsPageViewProps = {
|
||||
export const AppearanceSettingsPageView: FC<
|
||||
AppearanceSettingsPageViewProps
|
||||
> = ({ appearance, isEntitled, isPremium, permissions, onSaveAppearance }) => {
|
||||
const applicationNameForm = useFormik<{
|
||||
const form = useFormik<{
|
||||
application_name: string;
|
||||
}>({
|
||||
initialValues: {
|
||||
application_name: appearance.application_name,
|
||||
},
|
||||
onSubmit: (values) => onSaveAppearance(values),
|
||||
});
|
||||
const applicationNameFieldHelpers = getFormHelpers(applicationNameForm);
|
||||
|
||||
const logoForm = useFormik<{
|
||||
logo_url: string;
|
||||
}>({
|
||||
initialValues: {
|
||||
application_name: appearance.application_name,
|
||||
logo_url: appearance.logo_url,
|
||||
},
|
||||
onSubmit: (values) => onSaveAppearance(values),
|
||||
enableReinitialize: true,
|
||||
});
|
||||
const logoFieldHelpers = getFormHelpers(logoForm);
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
const fieldsDisabled = !isEntitled || form.isSubmitting;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>Appearance</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Customize the look and feel of your Coder deployment.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
<div className="flex flex-col gap-12">
|
||||
<div>
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>Appearance</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Customize the look and feel of your Coder deployment.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
<Badges>
|
||||
{isEntitled ? (
|
||||
isPremium ? (
|
||||
<PremiumBadge />
|
||||
<Badges>
|
||||
{isEntitled ? (
|
||||
isPremium ? (
|
||||
<PremiumBadge />
|
||||
) : (
|
||||
<EnterpriseBadge />
|
||||
)
|
||||
) : (
|
||||
<EnterpriseBadge />
|
||||
)
|
||||
) : (
|
||||
<PaywallPremium
|
||||
message="Appearance"
|
||||
description="With a Premium license, you can customize the appearance and branding of your deployment."
|
||||
documentationLink={docs("/admin/setup/appearance")}
|
||||
canViewPremium={permissions.viewAllLicenses}
|
||||
/>
|
||||
)}
|
||||
</Badges>
|
||||
|
||||
<Fieldset
|
||||
title="Application name"
|
||||
subtitle="Specify a custom application name to be displayed on the login page."
|
||||
validation={!isEntitled ? "This is an Enterprise only feature." : ""}
|
||||
onSubmit={applicationNameForm.handleSubmit}
|
||||
button={!isEntitled && <Button disabled>Submit</Button>}
|
||||
>
|
||||
<Input
|
||||
{...applicationNameFieldHelpers("application_name")}
|
||||
placeholder='Leave empty to display "Coder".'
|
||||
disabled={!isEntitled}
|
||||
aria-label="Application name"
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset
|
||||
title="Logo URL"
|
||||
subtitle="Specify a custom URL for your logo to be displayed on the sign in page and in the top left
|
||||
corner of the dashboard."
|
||||
validation={
|
||||
isEntitled
|
||||
? "An image with transparency and an aspect ratio of 3:1 or less will look best."
|
||||
: "This is an Enterprise only feature."
|
||||
}
|
||||
onSubmit={logoForm.handleSubmit}
|
||||
button={!isEntitled && <Button disabled>Submit</Button>}
|
||||
>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
{...logoFieldHelpers("logo_url")}
|
||||
placeholder="Leave empty to display the Coder logo."
|
||||
disabled={!isEntitled}
|
||||
aria-label="Logo URL"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<img
|
||||
alt=""
|
||||
src={logoForm.values.logo_url}
|
||||
className="size-6 max-w-full object-contain"
|
||||
// Hide broken image icon while users type incomplete URLs.
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
e.currentTarget.style.display = "inline";
|
||||
}}
|
||||
<PaywallPremium
|
||||
message="Appearance"
|
||||
description="With a Premium license, you can customize branding and announcement banners for your deployment."
|
||||
documentationLink={docs("/admin/setup/appearance")}
|
||||
canViewPremium={permissions.viewAllLicenses}
|
||||
/>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
)}
|
||||
</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."
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<FormFooter>
|
||||
<Button type="submit" disabled={fieldsDisabled}>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</VerticalForm>
|
||||
</div>
|
||||
|
||||
<AnnouncementBannerSettings
|
||||
isEntitled={isEntitled}
|
||||
@@ -142,6 +134,6 @@ export const AppearanceSettingsPageView: FC<
|
||||
onSaveAppearance({ announcement_banners: announcementBanners })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user