mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
refactor(site): align user settings layout with organization settings (#25016)
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user