refactor(site): replace MUI Stack, TextField, and Link with shared components (#27817)

> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

Continue the MUI → Tailwind/shadcn migration across a few auth and
settings surfaces.

- Create token form: MUI `TextField`/`MenuItem` → `FormField`, `Select`,
and `Input` (drops Emotion for section min-width)
- Create organization form: `TextField` → `FormField` / `Textarea`,
matching the organization info form
- SSO security section and external auth: MUI `Link`/`TextField` →
shared `Link`, `Input`, and `Label`
- Permission and IdP pill lists: MUI `Stack` → `flex flex-row gap-2`
- Minor link layout polish on Git device auth / external auth pages

---

_Also removes the now-unused `@emotion/css` dependency from
`site/package.json` (and the lockfile), which was the last usage of it.
This fixes the `knip` CI lint failure ("Unused dependencies:
@emotion/css")._
This commit is contained in:
Jake Howell
2026-08-05 14:07:04 +10:00
committed by GitHub
parent 1702bbb816
commit 7a4ae2649e
10 changed files with 181 additions and 130 deletions
-1
View File
@@ -43,7 +43,6 @@
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@emotion/cache": "11.14.0",
"@emotion/css": "11.13.5",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@fontsource-variable/geist": "5.3.0",
-16
View File
@@ -50,9 +50,6 @@ importers:
'@emotion/cache':
specifier: 11.14.0
version: 11.14.0
'@emotion/css':
specifier: 11.13.5
version: 11.13.5
'@emotion/react':
specifier: 11.14.0
version: 11.14.0(@types/react@19.2.17)(react@19.2.8)
@@ -774,9 +771,6 @@ packages:
'@emotion/cache@11.14.0':
resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==, tarball: https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz}
'@emotion/css@11.13.5':
resolution: {integrity: sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==, tarball: https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz}
'@emotion/hash@0.9.2':
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==, tarball: https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz}
@@ -6914,16 +6908,6 @@ snapshots:
'@emotion/weak-memoize': 0.4.0
stylis: 4.2.0
'@emotion/css@11.13.5':
dependencies:
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
'@emotion/sheet': 1.4.0
'@emotion/utils': 1.4.2
transitivePeerDependencies:
- supports-color
'@emotion/hash@0.9.2': {}
'@emotion/is-prop-valid@1.4.0':
@@ -146,9 +146,9 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
<br />
Then open the link below and paste it:
</p>
<div className="m-4 flex flex-col gap-1">
<div className="m-4 flex justify-center">
<Link
className="flex items-center justify-center gap-2 text-base"
className="inline-flex items-center gap-2 p-0 text-base font-medium [&_svg]:size-icon-xs [&_svg]:p-0"
href={externalAuthDevice.verification_uri}
target="_blank"
rel="noreferrer"
@@ -1,10 +1,7 @@
import { css } from "@emotion/css";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import type { FormikContextType } from "formik";
import { type FC, useEffect, useState } from "react";
import { type FC, useEffect, useId, useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "#/components/Button/Button";
import {
@@ -13,6 +10,16 @@ import {
FormSection,
HorizontalForm,
} from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { getFormHelpers, onChangeTrimmed } from "#/utils/formUtils";
import {
@@ -45,6 +52,8 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
now,
}) => {
const navigate = useNavigate();
const lifetimeId = useId();
const expiresOnId = useId();
const [expDays, setExpDays] = useState<number>(1);
const [lifetimeDays, setLifetimeDays] = useState<number | string>(
@@ -68,16 +77,16 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
<FormSection
title="Name"
description="What is this token for?"
classes={{ sectionInfo: classNames.sectionInfo }}
classes={{ sectionInfo: "min-w-[300px]" }}
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
<FormField
field={getFieldHelpers("name")}
label="Name"
required
onChange={onChangeTrimmed(form, () => setFormError(undefined))}
autoFocus
fullWidth
className="w-full"
/>
</FormFields>
</FormSection>
@@ -98,59 +107,69 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
"Please set a token expiration."
)
}
classes={{ sectionInfo: classNames.sectionInfo }}
classes={{ sectionInfo: "min-w-[300px]" }}
>
<FormFields>
<div className="flex flex-row gap-4">
<TextField
select
label="Lifetime"
required
defaultValue={determineDefaultLtValue(maxTokenLifetime)}
onChange={(event) => {
void setLifetimeDays(event.target.value);
}}
fullWidth
>
{filterByMaxTokenLifetime(maxTokenLifetime).map((lt) => (
<MenuItem key={lt.label} value={lt.value}>
{lt.label}
</MenuItem>
))}
<MenuItem
key={customLifetimeDay.label}
value={customLifetimeDay.value}
<div className="flex flex-col gap-2 flex-1">
<Label htmlFor={lifetimeId}>
Lifetime{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</Label>
<Select
value={String(lifetimeDays)}
onValueChange={setLifetimeDays}
>
{customLifetimeDay.label}
</MenuItem>
</TextField>
<SelectTrigger id={lifetimeId} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filterByMaxTokenLifetime(maxTokenLifetime).map((lt) => (
<SelectItem key={lt.label} value={String(lt.value)}>
{lt.label}
</SelectItem>
))}
<SelectItem value={String(customLifetimeDay.value)}>
{customLifetimeDay.label}
</SelectItem>
</SelectContent>
</Select>
</div>
{lifetimeDays === "custom" && (
<TextField
type="date"
label="Expires on"
defaultValue={dayjs().add(expDays, "day").format("YYYY-MM-DD")}
onChange={(event) => {
const lt = Math.ceil(
dayjs(event.target.value).diff(dayjs(), "day", true),
);
setExpDays(lt);
}}
inputProps={{
"data-pixel": "ignore",
min: dayjs().add(1, "day").format("YYYY-MM-DD"),
max: maxTokenLifetime
? dayjs()
.add(maxTokenLifetime / NANO_HOUR / 24, "day")
.format("YYYY-MM-DD")
: undefined,
required: true,
}}
fullWidth
InputLabelProps={{
required: true,
}}
/>
<div className="flex flex-col gap-2 flex-1">
<Label htmlFor={expiresOnId}>
Expires on{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</Label>
<Input
id={expiresOnId}
type="date"
data-pixel="ignore"
defaultValue={dayjs()
.add(expDays, "day")
.format("YYYY-MM-DD")}
min={dayjs().add(1, "day").format("YYYY-MM-DD")}
max={
maxTokenLifetime
? dayjs()
.add(maxTokenLifetime / NANO_HOUR / 24, "day")
.format("YYYY-MM-DD")
: undefined
}
required
onChange={(event) => {
const lt = Math.ceil(
dayjs(event.target.value).diff(dayjs(), "day", true),
);
setExpDays(lt);
}}
/>
</div>
)}
</div>
</FormFields>
@@ -168,9 +187,3 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
</HorizontalForm>
);
};
const classNames = {
sectionInfo: css`
min-width: 300px;
`,
};
@@ -1,4 +1,3 @@
import Link from "@mui/material/Link";
import { ExternalLinkIcon, RotateCwIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { ApiErrorResponse } from "#/api/errors";
@@ -6,6 +5,7 @@ import type { ExternalAuth, ExternalAuthDevice } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Avatar } from "#/components/Avatar/Avatar";
import { GitDeviceAuth } from "#/components/GitDeviceAuth/GitDeviceAuth";
import { Link } from "#/components/Link/Link";
import { SignInLayout } from "#/components/SignInLayout/SignInLayout";
import {
Tooltip,
@@ -56,6 +56,8 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
href={externalAuth.app_install_url}
target="_blank"
rel="noreferrer"
showExternalIcon={false}
className="p-0 text-sm font-medium"
>
{installTheApp}
</Link>
@@ -88,6 +90,8 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
href={install.account.profile_url}
target="_blank"
rel="noreferrer"
showExternalIcon={false}
className="p-0 after:hover:content-none"
>
<Avatar
src={install.account.avatar_url}
@@ -107,7 +111,7 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
</div>
)}
<div className="m-4 flex flex-col gap-1">
<div className="m-4 flex flex-col items-center gap-1">
{!hasInstallations && externalAuth.app_installable && (
<Alert severity="warning" className="m-4">
You must {installTheApp} to clone private repositories. Accounts
@@ -122,7 +126,8 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
href={externalAuth.app_install_url}
target="_blank"
rel="noreferrer"
className="flex items-center justify-center gap-2 text-base"
showExternalIcon={false}
className="inline-flex items-center gap-2 p-0 text-base font-medium [&_svg]:size-icon-xs [&_svg]:p-0"
>
<ExternalLinkIcon className="size-icon-xs" />
{externalAuth.installations.length > 0 ? "Configure" : "Install"}{" "}
@@ -130,8 +135,9 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
</Link>
)}
<Link
className="flex items-center justify-center gap-2 text-base"
className="inline-flex items-center gap-2 p-0 text-base font-medium [&_svg]:size-icon-xs [&_svg]:p-0"
href="#"
showExternalIcon={false}
onClick={() => {
onReauthenticate();
}}
@@ -1,4 +1,3 @@
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { ArrowLeftIcon } from "lucide-react";
import type { FC } from "react";
@@ -9,15 +8,19 @@ 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 { FormField } from "#/components/FormField/FormField";
import { IconField } from "#/components/IconField/IconField";
import { Label } from "#/components/Label/Label";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall";
import { Spinner } from "#/components/Spinner/Spinner";
import { Textarea } from "#/components/Textarea/Textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
import {
displayNameValidator,
@@ -59,6 +62,11 @@ export const CreateOrganizationPageView: FC<
});
const navigate = useNavigate();
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 (
<div className="flex flex-row font-medium">
@@ -130,23 +138,54 @@ export const CreateOrganizationPageView: FC<
disabled={form.isSubmitting}
className="flex flex-col gap-6 w-full border-none"
>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
fullWidth
<FormField
field={getFieldHelpers("name")}
label="Slug"
onChange={onChangeTrimmed(form)}
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
<FormField
field={getFieldHelpers("display_name")}
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
label="Description"
rows={2}
/>
<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)}
@@ -1,4 +1,3 @@
import Stack from "@mui/material/Stack";
import type { FC } from "react";
import type { Permission } from "#/api/typesGenerated";
import { Pill } from "#/components/Pill/Pill";
@@ -23,7 +22,7 @@ export const PermissionPillsList: FC<PermissionPillsListProps> = ({
const resourceTypes = getUniqueResourceTypes(permissions);
return (
<Stack direction="row" spacing={1}>
<div className="flex flex-row gap-2">
{permissions.length > 0 ? (
<PermissionsPill
resource={resourceTypes[0]}
@@ -39,7 +38,7 @@ export const PermissionPillsList: FC<PermissionPillsListProps> = ({
permissions={permissions.slice(1)}
/>
)}
</Stack>
</div>
);
};
@@ -1,4 +1,3 @@
import Stack from "@mui/material/Stack";
import type { FC } from "react";
import { Pill } from "#/components/Pill/Pill";
import {
@@ -14,7 +13,7 @@ interface PillListProps {
export const IdpPillList: FC<PillListProps> = ({ roles }) => {
return (
<Stack direction="row" spacing={1}>
<div className="flex flex-row gap-2">
{roles.length > 0 ? (
<Pill className="w-fit" type={isUUID(roles[0]) ? "error" : "muted"}>
{roles[0]}
@@ -24,7 +23,7 @@ export const IdpPillList: FC<PillListProps> = ({ roles }) => {
)}
{roles.length > 1 && <OverflowPill roles={roles.slice(1)} />}
</Stack>
</div>
);
};
@@ -1,7 +1,5 @@
import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField";
import { CircleCheckIcon, KeyIcon } from "lucide-react";
import { type FC, useState } from "react";
import { type FC, useId, useState } from "react";
import { useMutation } from "react-query";
import { API } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
@@ -15,11 +13,15 @@ import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { Link } from "#/components/Link/Link";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
type LoginTypeConfirmation =
@@ -250,6 +252,12 @@ const ConfirmLoginTypeChangeModal: FC<ConfirmLoginTypeChangeModalProps> = ({
onConfirm,
}) => {
const [password, setPassword] = useState("");
const passwordId = useId();
const errorId = useId();
const hasError = Boolean(error);
const errorMessage = error
? getErrorMessage(error, "Your password is incorrect")
: undefined;
const handleConfirm = () => {
onConfirm(password);
@@ -273,26 +281,30 @@ const ConfirmLoginTypeChangeModal: FC<ConfirmLoginTypeChangeModalProps> = ({
After changing your login type, you will not be able to change it
again. Are you sure you want to proceed and change your login type?
</p>
<TextField
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter") {
handleConfirm();
}
}}
error={Boolean(error)}
helperText={
error
? getErrorMessage(error, "Your password is incorrect")
: undefined
}
name="confirm-password"
id="confirm-password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
label="Confirm your password"
type="password"
/>
<div className="flex flex-col gap-2 text-left">
<Label htmlFor={passwordId}>Confirm your password</Label>
<Input
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter") {
handleConfirm();
}
}}
name="confirm-password"
id={passwordId}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
type="password"
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
className={cn(hasError && "border-border-destructive")}
/>
{hasError && (
<span id={errorId} className="text-xs text-content-destructive">
{errorMessage}
</span>
)}
</div>
</div>
}
/>
+3 -3
View File
@@ -1,4 +1,3 @@
import Link from "@mui/material/Link";
import cronParser from "cron-parser";
import cronstrue from "cronstrue";
import dayjs, { type Dayjs } from "dayjs";
@@ -10,6 +9,7 @@ import type { ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import type { Template, Workspace } from "#/api/typesGenerated";
import { HelpPopoverTitle } from "#/components/HelpPopover/HelpPopover";
import { Link } from "#/components/Link/Link";
import type { WorkspaceActivityStatus } from "#/modules/workspaces/activity";
import { isWorkspaceOn } from "./workspace";
@@ -140,8 +140,8 @@ export const autostopDisplay = (
{" "}
because this workspace has enabled autostop. You can disable autostop
from this workspace&apos;s{" "}
<Link component={RouterLink} to="settings/schedule">
schedule settings
<Link asChild showExternalIcon={false} size="sm" className="p-0">
<RouterLink to="settings/schedule">schedule settings</RouterLink>
</Link>
.
</span>