refactor(site): align user settings layout with organization settings (#25016)

This commit is contained in:
Kayla はな
2026-05-06 17:29:01 -06:00
committed by GitHub
parent 8c2b1c7d69
commit 9d1315ffba
17 changed files with 450 additions and 459 deletions
@@ -12,7 +12,7 @@ import { docs } from "#/utils/docs";
* All types of feature that we are currently supporting. Defined as record to
* ensure that we can't accidentally make typos when writing the badge text.
*/
export const featureStageBadgeTypes = {
const featureStageBadgeTypes = {
early_access: "early access",
beta: "beta",
} as const satisfies Record<string, ReactNode>;
-12
View File
@@ -1,12 +0,0 @@
import type { ComponentProps, JSX } from "react";
export const GitIcon = (props: ComponentProps<"svg">): JSX.Element => (
<svg
fill="currentColor"
{...props}
viewBox="0 0 96 96"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M92.71 44.408 52.591 4.291c-2.31-2.311-6.057-2.311-8.369 0l-8.33 8.332L46.459 23.19c2.456-.83 5.272-.273 7.229 1.685 1.969 1.97 2.521 4.81 1.67 7.275l10.186 10.185c2.465-.85 5.307-.3 7.275 1.671 2.75 2.75 2.75 7.206 0 9.958-2.752 2.751-7.208 2.751-9.961 0-2.068-2.07-2.58-5.11-1.531-7.658l-9.5-9.499v24.997c.67.332 1.303.774 1.861 1.332 2.75 2.75 2.75 7.206 0 9.959-2.75 2.749-7.209 2.749-9.957 0-2.75-2.754-2.75-7.21 0-9.959.68-.679 1.467-1.193 2.307-1.537v-25.23c-.84-.344-1.625-.853-2.307-1.537-2.083-2.082-2.584-5.14-1.516-7.698L31.798 16.715 4.288 44.222c-2.311 2.313-2.311 6.06 0 8.371l40.121 40.118c2.31 2.311 6.056 2.311 8.369 0L92.71 52.779c2.311-2.311 2.311-6.06 0-8.371z" />
</svg>
);
@@ -1,10 +1,14 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import { groupsForUser } from "#/api/queries/groups";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useAuthContext } from "#/contexts/auth/AuthProvider";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { Section } from "../Section";
import { AccountForm } from "./AccountForm";
import { AccountUserGroups } from "./AccountUserGroups";
@@ -22,7 +26,13 @@ const AccountPage: FC = () => {
return (
<div className="flex flex-col gap-12">
<Section title="Account" description="Update your account info">
<div>
<SettingsHeader>
<SettingsHeaderTitle>Account</SettingsHeaderTitle>
<SettingsHeaderDescription>
Update your account info.
</SettingsHeaderDescription>
</SettingsHeader>
<AccountForm
editable={permissions?.updateUsers ?? false}
email={me.email}
@@ -31,7 +41,7 @@ const AccountPage: FC = () => {
initialValues={{ username: me.username, name: me.name ?? "" }}
onSubmit={updateProfile}
/>
</Section>
</div>
{hasGroupsFeature && (
<AccountUserGroups
@@ -4,8 +4,12 @@ import type { Group } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { AvatarCard } from "#/components/Avatar/AvatarCard";
import { Loader } from "#/components/Loader/Loader";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { Section } from "../Section";
type AccountGroupsProps = {
groups: readonly Group[] | undefined;
@@ -21,21 +25,22 @@ export const AccountUserGroups: FC<AccountGroupsProps> = ({
const { showOrganizations } = useDashboard();
return (
<Section
title="Your groups"
layout="fluid"
description={
groups && (
<span>
<div>
<SettingsHeader>
<SettingsHeaderTitle hierarchy="secondary">
Your groups
</SettingsHeaderTitle>
{groups && (
<SettingsHeaderDescription>
You are in{" "}
<em className="not-italic text-content-primary font-semibold">
{groups.length} group
{groups.length !== 1 && "s"}
</em>
</span>
)
}
>
</SettingsHeaderDescription>
)}
</SettingsHeader>
<div className="flex flex-col gap-6">
{isApiError(error) && <ErrorAlert error={error} />}
@@ -63,6 +68,6 @@ export const AccountUserGroups: FC<AccountGroupsProps> = ({
{loading && <Loader />}
</div>
</Section>
</div>
);
};
@@ -8,6 +8,10 @@ import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { PreviewBadge } from "#/components/Badges/Badges";
import { Label } from "#/components/Label/Label";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
import {
SettingsHeader,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import { DEFAULT_THEME } from "#/theme";
import {
@@ -16,7 +20,6 @@ import {
terminalFonts,
} from "#/theme/constants";
import { cn } from "#/utils/cn";
import { Section } from "../Section";
// Display Geist Mono (the default monospace font) first, then the rest
// alphabetically. TerminalFontNames is auto-generated in alphabetical
@@ -64,19 +67,17 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
};
return (
<form>
<form className="flex flex-col gap-12">
{Boolean(error) && <ErrorAlert error={error} />}
<Section
title={
<div className="flex flex-row items-center gap-2">
<div>
<SettingsHeader>
<SettingsHeaderTitle>
<span>Theme</span>
<Spinner loading={isUpdating} size="sm" />
</div>
}
layout="fluid"
className="mb-12"
>
</SettingsHeaderTitle>
</SettingsHeader>
<div className="flex flex-row flex-wrap gap-4">
<AutoThemePreviewButton
displayName="Auto"
@@ -97,16 +98,16 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
onSelect={() => onChangeTheme("light")}
/>
</div>
</Section>
<Section
title={
<div className="flex flex-row items-center gap-2">
</div>
<div>
<SettingsHeader>
<SettingsHeaderTitle hierarchy="secondary">
<span id="fonts-radio-buttons-group-label">Terminal Font</span>
<Spinner loading={isUpdating} size="sm" />
</div>
}
layout="fluid"
>
</SettingsHeaderTitle>
</SettingsHeader>
<RadioGroup
aria-labelledby="fonts-radio-buttons-group-label"
defaultValue={currentTerminalFont}
@@ -128,7 +129,7 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
</div>
))}
</RadioGroup>
</Section>
</div>
</form>
);
};
@@ -9,7 +9,10 @@ import {
} from "#/api/queries/externalAuth";
import type { ExternalAuthLinkProvider } from "#/api/typesGenerated";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import { Section } from "../Section";
import {
SettingsHeader,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { ExternalAuthPageView } from "./ExternalAuthPageView";
const ExternalAuthPage: FC = () => {
@@ -24,7 +27,10 @@ const ExternalAuthPage: FC = () => {
const validateAppMutation = useMutation(validateExternalAuth(queryClient));
return (
<Section title="External Authentication" layout="fluid">
<>
<SettingsHeader>
<SettingsHeaderTitle>External Authentication</SettingsHeaderTitle>
</SettingsHeader>
<ExternalAuthPageView
isLoading={externalAuthsQuery.isLoading}
getAuthsError={externalAuthsQuery.error}
@@ -96,7 +102,7 @@ const ExternalAuthPage: FC = () => {
}
}}
/>
</Section>
</>
);
};
+27 -10
View File
@@ -1,7 +1,12 @@
import { type FC, Suspense } from "react";
import { Outlet } from "react-router";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "#/components/Breadcrumb/Breadcrumb";
import { Loader } from "#/components/Loader/Loader";
import { Margins } from "#/components/Margins/Margins";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { pageTitle } from "#/utils/page";
import { Sidebar } from "./Sidebar";
@@ -13,16 +18,28 @@ const Layout: FC = () => {
<>
<title>{pageTitle("Settings")}</title>
<Margins>
<div className="flex flex-row gap-12 py-12">
<Sidebar user={me} />
<Suspense fallback={<Loader />}>
<div className="w-full max-w-full">
<Outlet />
<div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="text-content-primary">
User Settings
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="h-px border-none bg-border" />
<section className="px-10 max-w-screen-2xl mx-auto">
<div className="flex flex-row gap-28 py-10">
<Sidebar user={me} />
<div className="grow">
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div>
</Suspense>
</div>
</Margins>
</div>
</section>
</div>
</>
);
};
@@ -18,6 +18,11 @@ import {
} from "#/api/queries/users";
import type { NotificationTemplate } from "#/api/typesGenerated";
import { Loader } from "#/components/Loader/Loader";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
@@ -35,7 +40,6 @@ import {
} from "#/modules/notifications/utils";
import type { Permissions } from "#/modules/permissions";
import { pageTitle } from "#/utils/page";
import { Section } from "../Section";
const NotificationsPage: FC = () => {
const { user, permissions } = useAuthenticated();
@@ -111,156 +115,157 @@ const NotificationsPage: FC = () => {
<>
<title>{pageTitle("Notifications Settings")}</title>
<Section
title="Notifications"
description="Control which notifications you receive."
layout="fluid"
>
{ready ? (
<div className="flex flex-col gap-8">
{Object.entries(allTemplatesByGroup).map(([group, templates]) => {
if (!canSeeNotificationGroup(group, permissions)) {
return null;
}
<SettingsHeader>
<SettingsHeaderTitle>Notifications</SettingsHeaderTitle>
<SettingsHeaderDescription>
Control which notifications you receive.
</SettingsHeaderDescription>
</SettingsHeader>
const allDisabled = templates.some((tpl) => {
return notificationIsDisabled(disabledPreferences.data, tpl);
});
{ready ? (
<div className="flex flex-col gap-8">
{Object.entries(allTemplatesByGroup).map(([group, templates]) => {
if (!canSeeNotificationGroup(group, permissions)) {
return null;
}
return (
<article
className="border border-solid rounded-lg overflow-hidden"
key={group}
>
<div className="flex flex-col">
<header className="flex items-center justify-start gap-2 bg-surface-secondary border-0 border-b border-solid px-4 py-3">
<div className="flex items-center gap-2">
<Switch
id={group}
checked={!allDisabled}
onCheckedChange={async (checked) => {
const updated = { ...disabledPreferences.data };
for (const tpl of templates) {
updated[tpl.id] = !checked;
}
await updatePreferences.mutateAsync(
{
template_disabled_map: updated,
const allDisabled = templates.some((tpl) => {
return notificationIsDisabled(disabledPreferences.data, tpl);
});
return (
<article
className="border border-solid rounded-lg overflow-hidden"
key={group}
>
<div className="flex flex-col">
<header className="flex items-center justify-start gap-2 bg-surface-secondary border-0 border-b border-solid px-4 py-3">
<div className="flex items-center gap-2">
<Switch
id={group}
checked={!allDisabled}
onCheckedChange={async (checked) => {
const updated = { ...disabledPreferences.data };
for (const tpl of templates) {
updated[tpl.id] = !checked;
}
await updatePreferences.mutateAsync(
{
template_disabled_map: updated,
},
{
onSuccess: () => {
toast.success(
"Notification preferences updated.",
);
},
{
onSuccess: () => {
toast.success(
"Notification preferences updated.",
);
},
onError: (error) => {
toast.error(
"Error updating notification preferences.",
{
description: getErrorDetail(error),
},
);
},
onError: (error) => {
toast.error(
"Error updating notification preferences.",
{
description: getErrorDetail(error),
},
);
},
);
}}
/>
</div>
<label htmlFor={group} className="font-medium text-sm">
{group}
</label>
</header>
{templates.map((tmpl) => {
const method = castNotificationMethod(
tmpl.method || dispatchMethods.data.default,
);
const Icon = methodIcons[method];
const label = methodLabels[method];
},
);
}}
/>
</div>
<label htmlFor={group} className="font-medium text-sm">
{group}
</label>
</header>
{templates.map((tmpl) => {
const method = castNotificationMethod(
tmpl.method || dispatchMethods.data.default,
);
const Icon = methodIcons[method];
const label = methodLabels[method];
const disabled = notificationIsDisabled(
disabledPreferences.data,
tmpl,
);
const disabled = notificationIsDisabled(
disabledPreferences.data,
tmpl,
);
return (
<Fragment key={tmpl.id}>
<div className="flex items-center justify-between gap-3 px-4 py-3 border-0 [&:not(:last-child)]:border-b border-solid">
<div className="flex items-center gap-2">
<Switch
id={tmpl.id}
checked={!disabled}
onCheckedChange={async (checked) => {
await updatePreferences.mutateAsync(
{
template_disabled_map: {
...disabledPreferences.data,
[tmpl.id]: !checked,
},
return (
<Fragment key={tmpl.id}>
<div className="flex items-center justify-between gap-3 px-4 py-3 border-0 [&:not(:last-child)]:border-b border-solid">
<div className="flex items-center gap-2">
<Switch
id={tmpl.id}
checked={!disabled}
onCheckedChange={async (checked) => {
await updatePreferences.mutateAsync(
{
template_disabled_map: {
...disabledPreferences.data,
[tmpl.id]: !checked,
},
{
onSuccess: () => {
toast.success(
"Notification preferences updated.",
);
},
onError: (error) => {
toast.error(
"Error updating notification preferences.",
{
description: getErrorDetail(error),
},
);
},
},
{
onSuccess: () => {
toast.success(
"Notification preferences updated.",
);
},
);
onError: (error) => {
toast.error(
"Error updating notification preferences.",
{
description: getErrorDetail(error),
},
);
},
},
);
// Clear the Tasks page warning dismissal when enabling a task notification
// This ensures that if the user disables task notifications again later,
// they will see the warning alert again.
if (
isTaskNotification(tmpl) &&
checked &&
preferencesQuery.data
) {
updatePreferencesMutation.mutate({
...preferencesQuery.data,
task_notification_alert_dismissed: false,
});
}
}}
/>
<label
htmlFor={tmpl.id}
className="font-medium text-sm"
>
{tmpl.name}
</label>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Icon
className="size-icon-sm text-content-secondary"
aria-label={label}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
Delivery via {label}
</TooltipContent>
</Tooltip>
// Clear the Tasks page warning dismissal when enabling a task notification
// This ensures that if the user disables task notifications again later,
// they will see the warning alert again.
if (
isTaskNotification(tmpl) &&
checked &&
preferencesQuery.data
) {
updatePreferencesMutation.mutate({
...preferencesQuery.data,
task_notification_alert_dismissed: false,
});
}
}}
/>
<label
htmlFor={tmpl.id}
className="font-medium text-sm"
>
{tmpl.name}
</label>
</div>
</Fragment>
);
})}
</div>
</article>
);
})}
</div>
) : (
<Loader />
)}
</Section>
<Tooltip>
<TooltipTrigger asChild>
<Icon
className="size-icon-sm text-content-secondary"
aria-label={label}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
Delivery via {label}
</TooltipContent>
</Tooltip>
</div>
</Fragment>
);
})}
</div>
</article>
);
})}
</div>
) : (
<Loader />
)}
</>
);
};
@@ -4,8 +4,11 @@ import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { getApps, revokeApp } from "#/api/queries/oauth2";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import {
SettingsHeader,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { Section } from "../Section";
import OAuth2ProviderPageView from "./OAuth2ProviderPageView";
const OAuth2ProviderPage: FC = () => {
@@ -19,7 +22,10 @@ const OAuth2ProviderPage: FC = () => {
);
return (
<Section title="OAuth2 Applications" layout="fluid">
<>
<SettingsHeader>
<SettingsHeaderTitle>OAuth2 Applications</SettingsHeaderTitle>
</SettingsHeader>
<OAuth2ProviderPageView
isLoading={userOAuth2AppsQuery.isLoading}
error={userOAuth2AppsQuery.error}
@@ -57,7 +63,7 @@ const OAuth2ProviderPage: FC = () => {
}}
/>
)}
</Section>
</>
);
};
@@ -4,7 +4,10 @@ import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { regenerateUserSSHKey, userSSHKey } from "#/api/queries/sshKeys";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Section } from "../Section";
import {
SettingsHeader,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { SSHKeysPageView } from "./SSHKeysPageView";
const SSHKeysPage: FC = () => {
@@ -19,14 +22,15 @@ const SSHKeysPage: FC = () => {
return (
<>
<Section title="SSH keys">
<SSHKeysPageView
isLoading={userSSHKeyQuery.isLoading}
getSSHKeyError={userSSHKeyQuery.error}
sshKey={userSSHKeyQuery.data}
onRegenerateClick={() => setIsConfirmingRegeneration(true)}
/>
</Section>
<SettingsHeader>
<SettingsHeaderTitle>SSH keys</SettingsHeaderTitle>
</SettingsHeader>
<SSHKeysPageView
isLoading={userSSHKeyQuery.isLoading}
getSSHKeyError={userSSHKeyQuery.error}
sshKey={userSSHKeyQuery.data}
onRegenerateClick={() => setIsConfirmingRegeneration(true)}
/>
<ConfirmDialog
type="delete"
@@ -8,8 +8,12 @@ import {
import type { UserQuietHoursScheduleResponse } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { Section } from "../Section";
import { ScheduleForm } from "./ScheduleForm";
const SchedulePage: FC = () => {
@@ -38,11 +42,14 @@ const SchedulePage: FC = () => {
}
return (
<Section
title="Quiet hours"
layout="fluid"
description="Workspaces may be automatically updated during your quiet hours, as configured by your administrators."
>
<>
<SettingsHeader>
<SettingsHeaderTitle>Quiet hours</SettingsHeaderTitle>
<SettingsHeaderDescription>
Workspaces may be automatically updated during your quiet hours, as
configured by your administrators.
</SettingsHeaderDescription>
</SettingsHeader>
<ScheduleForm
isLoading={mutationLoading}
initialValues={quietHoursSchedule as UserQuietHoursScheduleResponse}
@@ -55,7 +62,7 @@ const SchedulePage: FC = () => {
});
}}
/>
</Section>
</>
);
};
@@ -1,69 +0,0 @@
import type { FC, ReactNode } from "react";
import {
FeatureStageBadge,
type featureStageBadgeTypes,
} from "#/components/FeatureStageBadge/FeatureStageBadge";
type SectionLayout = "fixed" | "fluid";
interface SectionProps {
// Useful for testing
id?: string;
title?: ReactNode | string;
description?: ReactNode;
toolbar?: ReactNode;
alert?: ReactNode;
layout?: SectionLayout;
className?: string;
children?: ReactNode;
featureStage?: keyof typeof featureStageBadgeTypes;
}
const DESCRIPTION_CLASS =
"text-content-secondary text-base m-0 mt-1 leading-normal";
export const Section: FC<SectionProps> = ({
id,
title,
description,
toolbar,
alert,
className = "",
children,
layout = "fixed",
featureStage,
}) => {
return (
<section className={className} id={id} data-testid={id}>
<div className={layout === "fluid" ? "max-w-full" : "max-w-[500px]"}>
{(title || description) && (
<div className="mb-6 flex flex-row justify-between">
<div>
{title && (
<div className="flex flex-row items-center gap-4">
<h4 className="text-2xl font-medium m-0 mb-2">{title}</h4>
{featureStage && (
<FeatureStageBadge
contentType={featureStage}
size="md"
className="mb-[5px]"
/>
)}
</div>
)}
{description && typeof description === "string" && (
<p className={DESCRIPTION_CLASS}>{description}</p>
)}
{description && typeof description !== "string" && (
<div className={DESCRIPTION_CLASS}>{description}</div>
)}
</div>
{toolbar && <div>{toolbar}</div>}
</div>
)}
{alert && <div className="mb-2">{alert}</div>}
{children}
</div>
</section>
);
};
@@ -7,6 +7,11 @@ import { Button } from "#/components/Button/Button";
import { Form, FormFields } from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { PasswordField } from "#/components/PasswordField/PasswordField";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import { getFormHelpers } from "#/utils/formUtils";
@@ -64,34 +69,44 @@ export const SecurityForm: FC<SecurityFormProps> = ({
}
return (
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(error) && <ErrorAlert error={error} />}
<FormField
field={getFieldHelpers("old_password")}
label="Old Password"
type="password"
autoComplete="current-password"
/>
<PasswordField
field={getFieldHelpers("password")}
label="New Password"
autoComplete="new-password"
/>
<FormField
field={getFieldHelpers("confirm_password")}
label="Confirm Password"
type="password"
autoComplete="new-password"
/>
<>
<SettingsHeader>
<SettingsHeaderTitle hierarchy="secondary">
Password
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Update your account password.
</SettingsHeaderDescription>
</SettingsHeader>
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(error) && <ErrorAlert error={error} />}
<FormField
field={getFieldHelpers("old_password")}
label="Old Password"
type="password"
autoComplete="current-password"
/>
<PasswordField
field={getFieldHelpers("password")}
label="New Password"
autoComplete="new-password"
/>
<FormField
field={getFieldHelpers("confirm_password")}
label="Confirm Password"
type="password"
autoComplete="new-password"
/>
<div>
<Button disabled={isLoading} type="submit">
<Spinner loading={isLoading} />
Update password
</Button>
</div>
</FormFields>
</Form>
<div>
<Button disabled={isLoading} type="submit">
<Spinner loading={isLoading} />
Update password
</Button>
</div>
</FormFields>
</Form>
</>
);
};
@@ -4,8 +4,11 @@ import { toast } from "sonner";
import { API } from "#/api/api";
import { authMethods, updatePassword } from "#/api/queries/users";
import { Loader } from "#/components/Loader/Loader";
import {
SettingsHeader,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { Section } from "../Section";
import { SecurityForm } from "./SecurityForm";
import {
SingleSignOnSection,
@@ -71,9 +74,12 @@ export const SecurityPageView: FC<SecurityPageViewProps> = ({
}) => {
return (
<div className="flex flex-col gap-12">
<Section title="Security" description="Update your account password">
<div>
<SettingsHeader>
<SettingsHeaderTitle>Security</SettingsHeaderTitle>
</SettingsHeader>
<SecurityForm {...security.form} />
</Section>
</div>
{oidc && <SingleSignOnSection {...oidc.section} />}
</div>
);
@@ -15,8 +15,12 @@ import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { docs } from "#/utils/docs";
import { Section } from "../Section";
type LoginTypeConfirmation =
| {
@@ -134,65 +138,68 @@ export const SingleSignOnSection: FC<SingleSignOnSectionProps> = ({
const noSsoEnabled = !authMethods.github.enabled && !authMethods.oidc.enabled;
return (
<>
<Section
id="sso-section"
title="Single Sign On"
description="Authenticate in Coder using one-click"
>
<div className="grid gap-4">
{userLoginType.login_type === "password" ? (
<>
{authMethods.github.enabled && (
<Button
variant="outline"
size="lg"
className="w-full"
disabled={isUpdating}
onClick={() => openConfirmation("github")}
>
<ExternalImage src="/icon/github.svg" />
GitHub
</Button>
)}
<div id="sso-section" data-testid="sso-section">
<SettingsHeader>
<SettingsHeaderTitle hierarchy="secondary">
Single Sign On
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Authenticate in Coder using one-click.
</SettingsHeaderDescription>
</SettingsHeader>
{authMethods.oidc.enabled && (
<Button
variant="outline"
size="lg"
className="w-full"
disabled={isUpdating}
onClick={() => openConfirmation("oidc")}
>
<OIDCIcon oidcAuth={authMethods.oidc} />
{getOIDCLabel(authMethods.oidc)}
</Button>
)}
<div className="grid gap-4">
{userLoginType.login_type === "password" ? (
<>
{authMethods.github.enabled && (
<Button
variant="outline"
size="lg"
className="w-full"
disabled={isUpdating}
onClick={() => openConfirmation("github")}
>
<ExternalImage src="/icon/github.svg" />
GitHub
</Button>
)}
{noSsoEnabled && <SSOEmptyState />}
</>
) : (
<div className="bg-surface-secondary rounded-md border border-border border-solid p-4 flex gap-4 items-center text-sm">
<CircleCheckIcon className="text-content-success size-icon-xs" />
<span>
Authenticated with{" "}
<strong>
{userLoginType.login_type === "github"
? "GitHub"
: getOIDCLabel(authMethods.oidc)}
</strong>
</span>
<div className="leading-none ml-auto">
{userLoginType.login_type === "github" ? (
<ExternalImage src="/icon/github.svg" className="size-4" />
) : (
<OIDCIcon oidcAuth={authMethods.oidc} />
)}
</div>
{authMethods.oidc.enabled && (
<Button
variant="outline"
size="lg"
className="w-full"
disabled={isUpdating}
onClick={() => openConfirmation("oidc")}
>
<OIDCIcon oidcAuth={authMethods.oidc} />
{getOIDCLabel(authMethods.oidc)}
</Button>
)}
{noSsoEnabled && <SSOEmptyState />}
</>
) : (
<div className="bg-surface-secondary rounded-md border border-border border-solid p-4 flex gap-4 items-center text-sm">
<CircleCheckIcon className="text-content-success size-icon-xs" />
<span>
Authenticated with{" "}
<strong>
{userLoginType.login_type === "github"
? "GitHub"
: getOIDCLabel(authMethods.oidc)}
</strong>
</span>
<div className="leading-none ml-auto">
{userLoginType.login_type === "github" ? (
<ExternalImage src="/icon/github.svg" className="size-4" />
) : (
<OIDCIcon oidcAuth={authMethods.oidc} />
)}
</div>
)}
</div>
</Section>
</div>
)}
</div>
<ConfirmLoginTypeChangeModal
open={isConfirming}
@@ -201,7 +208,7 @@ export const SingleSignOnSection: FC<SingleSignOnSectionProps> = ({
onClose={closeConfirmation}
onConfirm={confirm}
/>
</>
</div>
);
};
+32 -44
View File
@@ -1,21 +1,10 @@
import {
BellIcon,
BrushIcon,
CalendarCogIcon,
FingerprintIcon,
KeyIcon,
LockIcon,
ShieldIcon,
UserIcon,
} from "lucide-react";
import type { FC } from "react";
import type { User } from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { GitIcon } from "#/components/Icons/GitIcon";
import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem,
SidebarHeader,
SidebarNavItem,
} from "#/components/Sidebar/Sidebar";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { getPrereleaseFlag } from "#/utils/buildInfo";
@@ -28,6 +17,8 @@ export const Sidebar: FC<SidebarProps> = ({ user }) => {
const { entitlements, experiments, buildInfo } = useDashboard();
const showSchedulePage =
entitlements.features.advanced_template_scheduling.enabled;
const showOAuth2Page =
experiments.includes("oauth2") || getPrereleaseFlag(buildInfo) === "devel";
return (
<BaseSidebar>
@@ -36,38 +27,35 @@ export const Sidebar: FC<SidebarProps> = ({ user }) => {
title={user.username}
subtitle={user.email}
/>
<SidebarNavItem href="account" icon={UserIcon}>
Account
</SidebarNavItem>
<SidebarNavItem href="appearance" icon={BrushIcon}>
Appearance
</SidebarNavItem>
<SidebarNavItem href="external-auth" icon={GitIcon}>
External Authentication
</SidebarNavItem>
{(experiments.includes("oauth2") ||
getPrereleaseFlag(buildInfo) === "devel") && (
<SidebarNavItem href="oauth2-provider" icon={ShieldIcon}>
OAuth2 Applications
</SidebarNavItem>
)}
{showSchedulePage && (
<SidebarNavItem href="schedule" icon={CalendarCogIcon}>
Schedule
</SidebarNavItem>
)}
<SidebarNavItem href="security" icon={LockIcon}>
Security
</SidebarNavItem>
<SidebarNavItem href="ssh-keys" icon={FingerprintIcon}>
SSH Keys
</SidebarNavItem>
<SidebarNavItem href="tokens" icon={KeyIcon}>
Tokens
</SidebarNavItem>
<SidebarNavItem href="notifications" icon={BellIcon}>
Notifications
</SidebarNavItem>
<div className="flex flex-col gap-1">
<SettingsSidebarNavItem href="account">Account</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="appearance">
Appearance
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="external-auth">
External Authentication
</SettingsSidebarNavItem>
{showOAuth2Page && (
<SettingsSidebarNavItem href="oauth2-provider">
OAuth2 Applications
</SettingsSidebarNavItem>
)}
{showSchedulePage && (
<SettingsSidebarNavItem href="schedule">
Schedule
</SettingsSidebarNavItem>
)}
<SettingsSidebarNavItem href="security">
Security
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="ssh-keys">
SSH Keys
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="tokens">Tokens</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="notifications">
Notifications
</SettingsSidebarNavItem>
</div>
</BaseSidebar>
);
};
@@ -3,8 +3,11 @@ import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router";
import type { APIKeyWithOwner } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { Section } from "../Section";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { useTokensData } from "./hooks";
import { TokensPageView } from "./TokensPageView";
@@ -31,32 +34,35 @@ const TokensPage: FC = () => {
return (
<>
<Section
title="Tokens"
className={cn(
"[&_code]:bg-surface-secondary [&_code]:text-content-primary",
"[&_code]:text-xs [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded-sm",
)}
description={
<>
Tokens are used to authenticate with the Coder API. You can create a
token with the Coder CLI using the <code>{cliCreateCommand}</code>{" "}
command.
</>
<SettingsHeader
actions={
<Button asChild variant="outline">
<RouterLink to="new">
<PlusIcon />
Add token
</RouterLink>
</Button>
}
layout="fluid"
>
<TokenActions />
<TokensPageView
tokens={tokens}
isLoading={isFetching}
hasLoaded={isFetched}
getTokensError={getTokensError}
onDelete={(token) => {
setTokenToDelete(token);
}}
/>
</Section>
<SettingsHeaderTitle>Tokens</SettingsHeaderTitle>
<SettingsHeaderDescription>
Tokens are used to authenticate with the Coder API. You can create a
token with the Coder CLI using the{" "}
<code className="bg-surface-secondary text-content-primary text-xs px-1 py-0.5 rounded-sm">
{cliCreateCommand}
</code>{" "}
command.
</SettingsHeaderDescription>
</SettingsHeader>
<TokensPageView
tokens={tokens}
isLoading={isFetching}
hasLoaded={isFetched}
getTokensError={getTokensError}
onDelete={(token) => {
setTokenToDelete(token);
}}
/>
<ConfirmDeleteDialog
queryKey={queryKey}
token={tokenToDelete}
@@ -66,15 +72,4 @@ const TokensPage: FC = () => {
);
};
const TokenActions: FC = () => (
<div className="flex flex-row justify-end gap-4 mb-2">
<Button asChild variant="outline">
<RouterLink to="new">
<PlusIcon />
Add token
</RouterLink>
</Button>
</div>
);
export default TokensPage;