feat(site): modernize OAuth2 applications settings UI (#27562)

Follow up to #27561 

Modernizes the Deployment Settings OAuth2 Applications create/edit/list
UI to match the AI Providers pattern: fat page views, card layout, and a
Formik + Yup form using shared field primitives.

- Rework CreateOAuth2AppPageView / EditOAuth2AppPageView into fat views
with provider-style layout (back link, avatar + title, bordered cards,
cancel/submit footer)
- Rewrite OAuth2AppForm with Formik/Yup, FormField descriptions, and
IconPickerField (live header avatar on create/edit)
- Order edit page as settings → endpoints (Client ID / Auth / Token via
CodeExample) → secrets
- Align list page row styling and add a Callback URL column
- Update Storybook stories for the new fat-view + form validation
behavior

| Old | New |
| --- | --- |
| <img width="2936" height="1802" alt="old-oauth2-application-create"
src="https://github.com/user-attachments/assets/98c1dec1-c273-43a7-a517-a31ae48bc17c"
/> | <img width="2936" height="1802" alt="new-oauth2-application-create"
src="https://github.com/user-attachments/assets/9d2e1d6b-6905-469f-b9e0-cf8ae3b14f3d"
/> |
| <img width="2936" height="1802" alt="old-oauth2-application-list"
src="https://github.com/user-attachments/assets/3d38daab-1d93-4acb-bff0-d7299da884fd"
/> | <img width="2936" height="1802" alt="new-oauth2-application-list"
src="https://github.com/user-attachments/assets/b3cc56ac-a810-4742-9ea7-27e20caa9bb7"
/> |
| <img width="2936" height="2030" alt="old-oauth2-application-update"
src="https://github.com/user-attachments/assets/544ad92b-ae2b-4b10-907d-c94bcc3d12c4"
/> | <img width="2936" height="3470" alt="new-oauth2-application-update"
src="https://github.com/user-attachments/assets/6e4d228d-8deb-4934-989a-b9c98b633509"
/> |
This commit is contained in:
Jake Howell
2026-07-30 02:46:09 +10:00
committed by GitHub
parent 659fb48a1d
commit 6c42309ccb
11 changed files with 882 additions and 659 deletions
+16 -12
View File
@@ -2,10 +2,13 @@ import type { QueryClient } from "react-query";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
const appsKey = ["oauth2-provider", "apps"];
const userAppsKey = (userId: string) => appsKey.concat(userId);
const appKey = (appId: string) => appsKey.concat(appId);
const appSecretsKey = (appId: string) => appKey(appId).concat("secrets");
const oauth2ProviderAppsKey = ["oauth2-provider", "apps"];
export const oauth2ProviderAppKey = (appId: string) =>
oauth2ProviderAppsKey.concat(appId);
export const oauth2ProviderAppSecretsKey = (appId: string) =>
oauth2ProviderAppKey(appId).concat("secrets");
const userAppsKey = (userId: string) => oauth2ProviderAppsKey.concat(userId);
export const getGitHubDevice = () => {
return {
@@ -23,14 +26,14 @@ export const getGitHubDeviceFlowCallback = (code: string, state: string) => {
export const getApps = (userId?: string) => {
return {
queryKey: userId ? appsKey.concat(userId) : appsKey,
queryKey: userId ? userAppsKey(userId) : oauth2ProviderAppsKey,
queryFn: () => API.getOAuth2ProviderApps({ user_id: userId }),
};
};
export const getApp = (id: string) => {
return {
queryKey: appKey(id),
queryKey: oauth2ProviderAppKey(id),
queryFn: () => API.getOAuth2ProviderApp(id),
};
};
@@ -40,7 +43,7 @@ export const postApp = (queryClient: QueryClient) => {
mutationFn: API.postOAuth2ProviderApp,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: appsKey,
queryKey: oauth2ProviderAppsKey,
});
},
};
@@ -56,8 +59,9 @@ export const putApp = (queryClient: QueryClient) => {
req: TypesGen.PutOAuth2ProviderAppRequest;
}) => API.putOAuth2ProviderApp(id, req),
onSuccess: async (app: TypesGen.OAuth2ProviderApp) => {
queryClient.setQueryData(oauth2ProviderAppKey(app.id), app);
await queryClient.invalidateQueries({
queryKey: appKey(app.id),
queryKey: oauth2ProviderAppsKey,
});
},
};
@@ -68,7 +72,7 @@ export const deleteApp = (queryClient: QueryClient) => {
mutationFn: API.deleteOAuth2ProviderApp,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: appsKey,
queryKey: oauth2ProviderAppsKey,
});
},
};
@@ -76,7 +80,7 @@ export const deleteApp = (queryClient: QueryClient) => {
export const getAppSecrets = (id: string) => {
return {
queryKey: appSecretsKey(id),
queryKey: oauth2ProviderAppSecretsKey(id),
queryFn: () => API.getOAuth2ProviderAppSecrets(id),
};
};
@@ -89,7 +93,7 @@ export const postAppSecret = (queryClient: QueryClient) => {
appId: string,
) => {
await queryClient.invalidateQueries({
queryKey: appSecretsKey(appId),
queryKey: oauth2ProviderAppSecretsKey(appId),
});
},
};
@@ -101,7 +105,7 @@ export const deleteAppSecret = (queryClient: QueryClient) => {
API.deleteOAuth2ProviderAppSecret(appId, secretId),
onSuccess: async (_: unknown, { appId }: { appId: string }) => {
await queryClient.invalidateQueries({
queryKey: appSecretsKey(appId),
queryKey: oauth2ProviderAppSecretsKey(appId),
});
},
};
@@ -1,57 +1,3 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail } from "#/api/errors";
import { postApp } from "#/api/queries/oauth2";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { pageTitle } from "#/utils/page";
import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
const CreateOAuth2AppPage: FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const postAppMutation = useMutation(postApp(queryClient));
const canCreateApp = permissions.createOAuth2App;
const defaultValues = {
name: searchParams.get("name") ?? "",
callback_url: searchParams.get("callback_url") ?? "",
icon: searchParams.get("icon") ?? "",
};
return (
<>
<title>{pageTitle("New OAuth2 Application")}</title>
<CreateOAuth2AppPageView
isUpdating={postAppMutation.isPending}
error={postAppMutation.error}
defaultValues={defaultValues}
createApp={async (req) => {
const mutation = postAppMutation.mutateAsync(req, {
onSuccess: (app) => {
navigate(
`/deployment/oauth2-provider/apps/${app.id}?created=true`,
);
},
});
toast.promise(mutation, {
loading: `Creating OAuth2 application "${req.name}"...`,
success: (app) =>
`OAuth2 application "${app.name}" created successfully.`,
error: (error) => ({
message: `Failed to create "${req.name}" OAuth2 application.`,
description: getErrorDetail(error),
}),
});
}}
canCreateApp={canCreateApp}
/>
</>
);
};
export default CreateOAuth2AppPage;
export default CreateOAuth2AppPageView;
@@ -1,50 +1,109 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { mockApiError } from "#/testHelpers/entities";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import {
MockPermissions,
MockUserOwner,
mockApiError,
} from "#/testHelpers/entities";
import { withAuthProvider, withToaster } from "#/testHelpers/storybook";
import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
const meta: Meta = {
const meta = {
title: "pages/DeploymentSettingsPage/CreateOAuth2AppPageView",
component: CreateOAuth2AppPageView,
args: {
canCreateApp: true,
},
};
export default meta;
type Story = StoryObj<typeof CreateOAuth2AppPageView>;
export const Updating: Story = {
args: {
isUpdating: true,
},
};
export const WithError: Story = {
args: {
error: mockApiError({
message: "Validation failed",
validations: [
parameters: {
user: MockUserOwner,
permissions: MockPermissions,
reactRouter: reactRouterParameters({
location: { path: "/deployment/oauth2-provider/apps/add" },
routing: [
{ path: "/deployment/oauth2-provider/apps", useStoryElement: true },
{
field: "name",
detail: "name error",
},
{
field: "callback_url",
detail: "url error",
},
{
field: "icon",
detail: "icon error",
path: "/deployment/oauth2-provider/apps/add",
useStoryElement: true,
},
],
}),
},
};
decorators: [withToaster, withAuthProvider],
} satisfies Meta<typeof CreateOAuth2AppPageView>;
export const NoPermissions: Story = {
args: {
canCreateApp: false,
export default meta;
type Story = StoryObj<typeof CreateOAuth2AppPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("heading", {
name: /add an oauth2 application/i,
}),
).toBeVisible();
await expect(
canvas.getByRole("button", { name: /create application/i }),
).toBeDisabled();
},
};
export const Default: Story = {};
export const WithValidationError: Story = {
beforeEach: () => {
spyOn(API, "postOAuth2ProviderApp").mockRejectedValue(
mockApiError({
message: "Validation failed",
validations: [
{ field: "name", detail: "name error" },
{ field: "callback_url", detail: "url error" },
{ field: "icon", detail: "icon error" },
],
}),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(await canvas.findByLabelText(/^name/i), "test-app");
await userEvent.type(
canvas.getByLabelText(/callback url/i),
"https://example.com/callback",
);
await userEvent.click(
canvas.getByRole("button", { name: /create application/i }),
);
await expect(await canvas.findByText("name error")).toBeVisible();
await expect(canvas.getByText("url error")).toBeVisible();
await expect(canvas.getByText("icon error")).toBeVisible();
},
};
export const InvalidName: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nameInput = await canvas.findByLabelText(/^name/i);
await userEvent.type(nameInput, "Foo@Application");
await userEvent.tab();
await expect(
await canvas.findByText(
/special characters \(e\.g\.: !, @, #\) are not supported/i,
),
).toBeVisible();
await expect(
canvas.getByRole("button", { name: /create application/i }),
).toBeDisabled();
},
};
export const NoPermissions: Story = {
parameters: {
permissions: {
...MockPermissions,
createOAuth2App: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("button", { name: /create application/i }),
).toBeDisabled();
},
};
@@ -1,62 +1,85 @@
import { ChevronLeftIcon } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { ArrowLeftIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { Link, useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { postApp } from "#/api/queries/oauth2";
import { Avatar } from "#/components/Avatar/Avatar";
import { Button } from "#/components/Button/Button";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { pageTitle } from "#/utils/page";
import { OAuth2AppForm } from "./OAuth2AppForm";
type CreateOAuth2AppProps = {
isUpdating: boolean;
createApp: (req: TypesGen.PostOAuth2ProviderAppRequest) => void;
error?: unknown;
defaultValues?: {
name: string;
callback_url: string;
icon: string;
const BACK_HREF = "/deployment/oauth2-provider/apps";
export const CreateOAuth2AppPageView: FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const postAppMutation = useMutation(postApp(queryClient));
const defaultValues = {
name: searchParams.get("name") ?? "",
callback_url: searchParams.get("callback_url") ?? "",
icon: searchParams.get("icon") ?? "",
};
canCreateApp: boolean;
};
const [icon, setIcon] = useState(defaultValues.icon);
export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
isUpdating,
createApp,
error,
defaultValues,
canCreateApp,
}) => {
return (
<>
<div className="flex flex-row gap-4 items-baseline justify-between">
<SettingsHeader>
<title>{pageTitle("Add an OAuth2 application")}</title>
<Button variant="subtle" asChild className="-ml-3">
<Link to={BACK_HREF}>
<ArrowLeftIcon />
<span>Back to applications</span>
</Link>
</Button>
<div className="flex flex-col gap-6 pt-6">
<div className="flex items-center gap-4 min-w-0">
<Avatar variant="icon" size="lg" src={icon} fallback="App" />
<SettingsHeaderTitle>Add an OAuth2 application</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure an application to use Coder as an OAuth2 provider.
</SettingsHeaderDescription>
</SettingsHeader>
</div>
<p className="text-sm text-content-secondary m-0">
Configure an application to use Coder as an OAuth2 provider.
</p>
<Button variant="outline" asChild>
<RouterLink to="/deployment/oauth2-provider/apps">
<ChevronLeftIcon />
All OAuth2 Applications
</RouterLink>
</Button>
</div>
<div className="flex flex-col gap-4">
{error ? <ErrorAlert error={error} /> : undefined}
<OAuth2AppForm
onSubmit={createApp}
isUpdating={isUpdating}
error={error}
defaultValues={defaultValues}
disabled={!canCreateApp}
/>
<div className="border border-solid p-6 rounded-lg">
<OAuth2AppForm
onSubmit={async (req) => {
try {
const app = await postAppMutation.mutateAsync(req);
toast.success(
`OAuth2 application "${app.name}" created successfully.`,
);
// Awaited so the form's submitting state stays true through
// navigation, keeping the unsaved-changes prompt suppressed.
await navigate(
`/deployment/oauth2-provider/apps/${app.id}?created=true`,
);
} catch (error) {
toast.error(
getErrorMessage(
error,
req.name.trim()
? `Failed to create "${req.name}" OAuth2 application.`
: "Failed to create OAuth2 application.",
),
{ description: getErrorDetail(error) },
);
}
}}
isUpdating={postAppMutation.isPending}
error={postAppMutation.error}
defaultValues={defaultValues}
disabled={!permissions.createOAuth2App}
onIconChange={setIcon}
/>
</div>
</div>
</>
);
@@ -1,135 +1,3 @@
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail } from "#/api/errors";
import * as oauth2 from "#/api/queries/oauth2";
import type * as TypesGen from "#/api/typesGenerated";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { pageTitle } from "#/utils/page";
import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
const EditOAuth2AppPage: FC = () => {
const navigate = useNavigate();
const { permissions } = useAuthenticated();
const { appId } = useParams() as { appId: string };
// When a new secret is created it is returned with the full secret. This is
// the only time it will be visible. The secret list only returns a truncated
// version of the secret (for differentiation purposes). Once the user
// acknowledges the secret we will clear it from the state.
const [fullNewSecret, setFullNewSecret] =
useState<TypesGen.OAuth2ProviderAppSecretFull>();
const queryClient = useQueryClient();
const appQuery = useQuery(oauth2.getApp(appId));
const putAppMutation = useMutation(oauth2.putApp(queryClient));
const deleteAppMutation = useMutation(oauth2.deleteApp(queryClient));
const secretsQuery = useQuery({
...oauth2.getAppSecrets(appId),
enabled: permissions.viewOAuth2AppSecrets,
});
const postSecretMutation = useMutation(oauth2.postAppSecret(queryClient));
const deleteSecretMutation = useMutation(oauth2.deleteAppSecret(queryClient));
return (
<>
<title>{pageTitle("Edit OAuth2 Application")}</title>
<EditOAuth2AppPageView
app={appQuery.data}
secrets={secretsQuery.data}
isLoadingApp={appQuery.isLoading}
isLoadingSecrets={secretsQuery.isLoading}
mutatingResource={{
updateApp: putAppMutation.isPending,
deleteApp: deleteAppMutation.isPending,
createSecret: postSecretMutation.isPending,
deleteSecret: deleteSecretMutation.isPending,
}}
fullNewSecret={fullNewSecret}
ackFullNewSecret={() => setFullNewSecret(undefined)}
error={
appQuery.error ||
putAppMutation.error ||
deleteAppMutation.error ||
secretsQuery.error ||
postSecretMutation.error ||
deleteSecretMutation.error
}
updateApp={async (req) => {
const mutation = putAppMutation.mutateAsync(
{ id: appId, req },
{
onSuccess: () => {
navigate("/deployment/oauth2-provider/apps?updated=true");
},
},
);
toast.promise(mutation, {
success: `Successfully updated the OAuth2 application "${req.name}".`,
error: (error) => ({
message: `Failed to update "${req.name}" OAuth2 application.`,
description: getErrorDetail(error),
}),
});
}}
deleteApp={async (name) => {
const mutation = deleteAppMutation.mutateAsync(appId, {
onSuccess: () => {
toast.success(
`You have successfully deleted the "${name}" OAuth2 application.`,
);
navigate("/deployment/oauth2-provider/apps?deleted=true");
},
});
toast.promise(mutation, {
success: `You have successfully deleted the "${name}" OAuth2 application.`,
error: (error) => ({
message: `Failed to delete "${name}" OAuth2 application.`,
description: getErrorDetail(error),
}),
});
}}
generateAppSecret={async () => {
const mutation = postSecretMutation.mutateAsync(appId, {
onSuccess: (secret) => {
setFullNewSecret(secret);
},
});
toast.promise(mutation, {
success: "Successfully generated OAuth2 client secret.",
error: (error) => ({
message: "Failed to generate OAuth2 client secret.",
description: getErrorDetail(error),
}),
});
}}
deleteAppSecret={async (secretId: string) => {
const mutation = deleteSecretMutation.mutateAsync(
{ appId, secretId },
{
onSuccess: () => {
if (fullNewSecret?.id === secretId) {
setFullNewSecret(undefined);
}
},
},
);
toast.promise(mutation, {
success: "Successfully deleted an OAuth2 client secret.",
error: (error) => ({
message: "Failed to delete OAuth2 client secret.",
description: getErrorDetail(error),
}),
});
}}
canEditApp={permissions.editOAuth2App}
canDeleteApp={permissions.deleteOAuth2App}
canViewAppSecrets={permissions.viewOAuth2AppSecrets}
/>
</>
);
};
export default EditOAuth2AppPage;
export default EditOAuth2AppPageView;
@@ -1,88 +1,154 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import {
oauth2ProviderAppKey,
oauth2ProviderAppSecretsKey,
} from "#/api/queries/oauth2";
import {
MockOAuth2ProviderAppSecrets,
MockOAuth2ProviderApps,
MockPermissions,
MockUserOwner,
mockApiError,
} from "#/testHelpers/entities";
import { withAuthProvider, withToaster } from "#/testHelpers/storybook";
import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
const meta: Meta = {
const mockApp = MockOAuth2ProviderApps[0];
const appId = mockApp.id;
const routingFor = (path: string) =>
reactRouterParameters({
location: { path },
routing: [
{ path: "/deployment/oauth2-provider/apps", useStoryElement: true },
{
path: "/deployment/oauth2-provider/apps/:appId",
useStoryElement: true,
},
],
});
const meta = {
title: "pages/DeploymentSettingsPage/EditOAuth2AppPageView",
component: EditOAuth2AppPageView,
args: {
canEditApp: true,
canDeleteApp: true,
canViewAppSecrets: true,
parameters: {
user: MockUserOwner,
permissions: MockPermissions,
reactRouter: routingFor(`/deployment/oauth2-provider/apps/${appId}`),
},
};
export default meta;
decorators: [withToaster, withAuthProvider],
} satisfies Meta<typeof EditOAuth2AppPageView>;
export default meta;
type Story = StoryObj<typeof EditOAuth2AppPageView>;
export const LoadingApp: Story = {
args: {
isLoadingApp: true,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
},
};
export const LoadingSecrets: Story = {
args: {
app: MockOAuth2ProviderApps[0],
isLoadingSecrets: true,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
},
};
export const WithError: Story = {
args: {
app: MockOAuth2ProviderApps[0],
secrets: MockOAuth2ProviderAppSecrets,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
error: mockApiError({
message: "Validation failed",
validations: [
{
field: "name",
detail: "name error",
},
{
field: "callback_url",
detail: "url error",
},
{
field: "icon",
detail: "icon error",
},
],
}),
},
};
export const Default: Story = {
args: {
app: MockOAuth2ProviderApps[0],
secrets: MockOAuth2ProviderAppSecrets,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
parameters: {
queries: [
{ key: oauth2ProviderAppKey(appId), data: mockApp },
{
key: oauth2ProviderAppSecretsKey(appId),
data: MockOAuth2ProviderAppSecrets,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText(mockApp.name)).toBeVisible();
await expect(
canvas.getByRole("button", { name: /update application/i }),
).toBeVisible();
await expect(
canvas.getByRole("table", { name: "OAuth2 client secrets" }),
).toBeVisible();
},
};
export const Loading: Story = {
parameters: {
queries: [],
},
beforeEach: () => {
spyOn(API, "getOAuth2ProviderApp").mockReturnValue(new Promise(() => {}));
},
};
export const WithValidationError: Story = {
parameters: {
queries: [
{ key: oauth2ProviderAppKey(appId), data: mockApp },
{
key: oauth2ProviderAppSecretsKey(appId),
data: MockOAuth2ProviderAppSecrets,
},
],
},
beforeEach: () => {
spyOn(API, "putOAuth2ProviderApp").mockRejectedValue(
mockApiError({
message: "Validation failed",
validations: [
{ field: "name", detail: "name error" },
{ field: "callback_url", detail: "url error" },
{ field: "icon", detail: "icon error" },
],
}),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(await canvas.findByLabelText(/^name/i), "-updated");
const submit = await canvas.findByRole("button", {
name: /update application/i,
});
await userEvent.click(submit);
await expect(await canvas.findByText("name error")).toBeVisible();
await expect(canvas.getByText("url error")).toBeVisible();
await expect(canvas.getByText("icon error")).toBeVisible();
},
};
export const DeleteDialogOpen: Story = {
parameters: {
queries: [
{ key: oauth2ProviderAppKey(appId), data: mockApp },
{
key: oauth2ProviderAppSecretsKey(appId),
data: MockOAuth2ProviderAppSecrets,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButton = await canvas.findByRole("button", {
name: /^delete$/i,
});
await userEvent.click(deleteButton);
await expect(await screen.findByRole("dialog")).toBeInTheDocument();
await expect(await screen.findByText(/irreversible/i)).toBeInTheDocument();
},
};
export const NoSecretPermissions: Story = {
parameters: {
permissions: {
...MockPermissions,
viewOAuth2AppSecrets: false,
deleteOAuth2App: false,
},
queries: [{ key: oauth2ProviderAppKey(appId), data: mockApp }],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText(mockApp.name)).toBeVisible();
await expect(
canvas.queryByRole("table", { name: "OAuth2 client secrets" }),
).not.toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: /^delete$/i }),
).not.toBeInTheDocument();
},
};
@@ -1,21 +1,28 @@
import { ChevronLeftIcon, CopyIcon } from "lucide-react";
import { isAxiosError } from "axios";
import { ArrowLeftIcon } from "lucide-react";
import { type FC, useState } from "react";
import { Link as RouterLink, useSearchParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
Link,
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import * as oauth2 from "#/api/queries/oauth2";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Avatar } from "#/components/Avatar/Avatar";
import { Button } from "#/components/Button/Button";
import { CodeExample } from "#/components/CodeExample/CodeExample";
import { CopyableValue } from "#/components/CopyableValue/CopyableValue";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import { Loader } from "#/components/Loader/Loader";
import { Separator } from "#/components/Separator/Separator";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
@@ -25,81 +32,285 @@ import {
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { createDayString } from "#/utils/createDayString";
import { pageTitle } from "#/utils/page";
import { OAuth2AppForm } from "./OAuth2AppForm";
type MutatingResource = {
updateApp: boolean;
createSecret: boolean;
deleteApp: boolean;
deleteSecret: boolean;
};
const BACK_HREF = "/deployment/oauth2-provider/apps";
type EditOAuth2AppProps = {
app?: TypesGen.OAuth2ProviderApp;
isLoadingApp: boolean;
isLoadingSecrets: boolean;
// mutatingResource indicates which resources, if any, are currently being
// mutated.
mutatingResource: MutatingResource;
updateApp: (req: TypesGen.PutOAuth2ProviderAppRequest) => void;
deleteApp: (name: string) => void;
generateAppSecret: () => void;
deleteAppSecret: (id: string) => void;
canEditApp: boolean;
canDeleteApp: boolean;
canViewAppSecrets: boolean;
secrets?: readonly TypesGen.OAuth2ProviderAppSecret[];
fullNewSecret?: TypesGen.OAuth2ProviderAppSecretFull;
ackFullNewSecret: () => void;
error?: unknown;
};
export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
app,
isLoadingApp,
isLoadingSecrets,
mutatingResource,
updateApp,
deleteApp,
generateAppSecret,
deleteAppSecret,
canEditApp,
canDeleteApp,
canViewAppSecrets,
secrets,
fullNewSecret,
ackFullNewSecret,
error,
}) => {
export const EditOAuth2AppPageView: FC = () => {
const { appId } = useParams<{ appId: string }>();
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [showDelete, setShowDelete] = useState<boolean>(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [iconOverride, setIconOverride] = useState<string>();
// When a new secret is created it is returned with the full secret. This is
// the only time it will be visible. The secret list only returns a truncated
// version. Once the user acknowledges the secret we clear it from state.
const [fullNewSecret, setFullNewSecret] =
useState<TypesGen.OAuth2ProviderAppSecretFull>();
const appQuery = useQuery({
...oauth2.getApp(appId ?? ""),
enabled: Boolean(appId),
});
const secretsQuery = useQuery({
...oauth2.getAppSecrets(appId ?? ""),
enabled: Boolean(appId) && permissions.viewOAuth2AppSecrets,
});
const putAppMutation = useMutation(oauth2.putApp(queryClient));
const deleteAppMutation = useMutation(oauth2.deleteApp(queryClient));
const postSecretMutation = useMutation(oauth2.postAppSecret(queryClient));
const deleteSecretMutation = useMutation(oauth2.deleteAppSecret(queryClient));
const app = appQuery.data;
const title = (
<title>{pageTitle(app?.name ?? "Loading...", "OAuth2 applications")}</title>
);
if (!appId) {
return <Navigate to={BACK_HREF} replace />;
}
if (appQuery.isLoading) {
return (
<>
{title}
<Loader fullscreen />
</>
);
}
if (appQuery.isError) {
const status = isAxiosError(appQuery.error)
? appQuery.error.response?.status
: undefined;
if (status === 404) {
return <Navigate to={BACK_HREF} replace />;
}
return (
<>
{title}
<div className="flex flex-col gap-4">
<p className="text-content-secondary m-0">
{getErrorMessage(
appQuery.error,
"Failed to load OAuth2 application.",
)}
</p>
<Button variant="subtle" asChild className="-ml-3">
<Link to={BACK_HREF}>
<ArrowLeftIcon />
<span>Back to applications</span>
</Link>
</Button>
</div>
</>
);
}
if (!app) {
return <Navigate to={BACK_HREF} replace />;
}
const canEditApp = permissions.editOAuth2App;
const canDeleteApp = permissions.deleteOAuth2App;
const canViewAppSecrets = permissions.viewOAuth2AppSecrets;
const isMutating =
putAppMutation.isPending ||
deleteAppMutation.isPending ||
postSecretMutation.isPending ||
deleteSecretMutation.isPending;
return (
<>
<div className="flex flex-row gap-4 items-baseline justify-between">
<SettingsHeader>
<SettingsHeaderTitle>Edit OAuth2 application</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure an application to use Coder as an OAuth2 provider.
</SettingsHeaderDescription>
</SettingsHeader>
{title}
<Button variant="outline" asChild>
<RouterLink to="/deployment/oauth2-provider/apps">
<ChevronLeftIcon />
All OAuth2 Applications
</RouterLink>
<div className="flex justify-between items-center">
<Button variant="subtle" asChild className="-ml-3">
<Link to={BACK_HREF}>
<ArrowLeftIcon />
<span>Back to applications</span>
</Link>
</Button>
{canDeleteApp && (
<Button
type="button"
variant="destructive"
disabled={isMutating}
onClick={() => setDeleteDialogOpen(true)}
>
<span>Delete</span>
</Button>
)}
</div>
<div className="flex flex-col gap-6 pt-6">
<div className="flex items-center gap-4 min-w-0">
<Avatar
variant="icon"
size="lg"
src={iconOverride ?? app.icon}
fallback={app.name}
/>
<SettingsHeaderTitle>
<span className="block min-w-0 truncate">{app.name}</span>
</SettingsHeaderTitle>
</div>
<p className="text-sm text-content-secondary m-0">
Configure this application to use Coder as an OAuth2 provider.
</p>
{searchParams.has("created") && (
<Alert severity="info" dismissible>
Your OAuth2 application has been created. Generate a client secret
below to start using your application.
</Alert>
)}
<dl className="m-0 flex flex-col gap-1.5">
<EndpointField label="Client ID" value={app.id} />
<EndpointField
label="Authorization URL"
value={app.endpoints.authorization}
/>
<EndpointField label="Token URL" value={app.endpoints.token} />
</dl>
{secretsQuery.error ? (
<ErrorAlert error={secretsQuery.error} />
) : undefined}
<div className="border border-solid p-6 rounded-lg flex flex-col gap-4">
<h2 className="m-0 text-xl font-semibold">Settings</h2>
<OAuth2AppForm
key={app.id}
app={app}
onSubmit={async (req) => {
try {
const updated = await putAppMutation.mutateAsync({
id: appId,
req,
});
toast.success(
`Successfully updated the OAuth2 application "${updated.name}".`,
);
} catch (error) {
toast.error(
getErrorMessage(
error,
`Failed to update "${req.name}" OAuth2 application.`,
),
{ description: getErrorDetail(error) },
);
}
}}
isUpdating={putAppMutation.isPending}
error={putAppMutation.error}
disabled={!canEditApp}
onIconChange={setIconOverride}
/>
</div>
{canViewAppSecrets && (
<div className="border border-solid p-6 rounded-lg flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center justify-between">
<h2 className="m-0 text-xl font-semibold">Client secrets</h2>
<Button
disabled={postSecretMutation.isPending || isMutating}
type="button"
onClick={() => {
postSecretMutation.mutate(appId, {
onSuccess: (secret) => {
setFullNewSecret(secret);
toast.success(
"Successfully generated OAuth2 client secret.",
);
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
"Failed to generate OAuth2 client secret.",
),
{ description: getErrorDetail(error) },
);
},
});
}}
>
<Spinner loading={postSecretMutation.isPending} />
Generate secret
</Button>
</div>
<Table aria-label="OAuth2 client secrets">
<TableHeader>
<TableRow>
<TableHead className="w-[80%]">Secret</TableHead>
<TableHead className="w-[20%]">Last used</TableHead>
<TableHead className="w-[1%]" />
</TableRow>
</TableHeader>
<TableBody size="lg">
{secretsQuery.isLoading && <TableLoader />}
{!secretsQuery.isLoading &&
!secretsQuery.error &&
(!secretsQuery.data || secretsQuery.data.length === 0) && (
<TableEmpty message="No client secrets have been generated." />
)}
{!secretsQuery.isLoading &&
secretsQuery.data?.map((secret) => (
<OAuth2SecretRow
key={secret.id}
secret={secret}
isDeleting={deleteSecretMutation.isPending}
onDelete={(secretId) => {
deleteSecretMutation.mutate(
{ appId, secretId },
{
onSuccess: () => {
if (fullNewSecret?.id === secretId) {
setFullNewSecret(undefined);
}
toast.success(
"Successfully deleted an OAuth2 client secret.",
);
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
"Failed to delete OAuth2 client secret.",
),
{ description: getErrorDetail(error) },
);
},
},
);
}}
/>
))}
</TableBody>
</Table>
</div>
)}
</div>
{fullNewSecret && (
<ConfirmDialog
hideCancel
open={Boolean(fullNewSecret)}
onConfirm={ackFullNewSecret}
onClose={ackFullNewSecret}
onConfirm={() => setFullNewSecret(undefined)}
onClose={() => setFullNewSecret(undefined)}
title="OAuth2 client secret"
confirmText="OK"
description={
@@ -117,182 +328,97 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
/>
)}
<div className="flex flex-col gap-4">
{searchParams.has("created") && (
<Alert severity="info" dismissible>
Your OAuth2 application has been created. Generate a client secret
below to start using your application.
</Alert>
)}
{error ? <ErrorAlert error={error} /> : undefined}
{isLoadingApp && <Loader />}
{!isLoadingApp && app && (
<>
<DeleteDialog
isOpen={showDelete}
confirmLoading={mutatingResource.deleteApp}
name={app.name}
entity="OAuth2 application"
info="Deleting this OAuth2 application will immediately invalidate all active sessions and API keys associated with it. Users currently authenticated through this application will be logged out and need to re-authenticate."
onConfirm={() => deleteApp(app.name)}
onCancel={() => setShowDelete(false)}
/>
<dl className="grid [grid-template-columns:max-content_auto] [&>dd]:ml-2.5 [&>dt]:font-bold">
<dt>Client ID</dt>
<dd>
<CopyableValue value={app.id} side="right">
{app.id} <CopyIcon className="size-icon-xs" />
</CopyableValue>
</dd>
<dt>Authorization URL</dt>
<dd>
<CopyableValue value={app.endpoints.authorization} side="right">
{app.endpoints.authorization}{" "}
<CopyIcon className="size-icon-xs" />
</CopyableValue>
</dd>
<dt>Token URL</dt>
<dd>
<CopyableValue value={app.endpoints.token} side="right">
{app.endpoints.token} <CopyIcon className="size-icon-xs" />
</CopyableValue>
</dd>
</dl>
<Separator className="my-2" />
<OAuth2AppForm
app={app}
onSubmit={updateApp}
isUpdating={mutatingResource.updateApp}
error={error}
actions={
<Button
variant="destructive"
onClick={() => setShowDelete(true)}
disabled={!canDeleteApp}
>
Delete&hellip;
</Button>
}
disabled={!canEditApp}
/>
{canViewAppSecrets && (
<>
<Separator className="my-2" />
<OAuth2AppSecretsTable
secrets={secrets}
generateAppSecret={generateAppSecret}
deleteAppSecret={deleteAppSecret}
isLoadingSecrets={isLoadingSecrets}
mutatingResource={mutatingResource}
/>
</>
)}
</>
)}
</div>
<DeleteDialog
key={app.name}
isOpen={deleteDialogOpen}
title="Delete OAuth2 application"
entity="OAuth2 application"
name={app.name}
info="Deleting this OAuth2 application will immediately invalidate all active sessions and API keys associated with it. Users currently authenticated through this application will be logged out and need to re-authenticate."
confirmLoading={deleteAppMutation.isPending}
onCancel={() => setDeleteDialogOpen(false)}
onConfirm={() => {
deleteAppMutation.mutate(appId, {
onSuccess: () => {
toast.success(
`You have successfully deleted the "${app.name}" OAuth2 application.`,
);
setDeleteDialogOpen(false);
void navigate(BACK_HREF, { replace: true });
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
`Failed to delete "${app.name}" OAuth2 application.`,
),
{ description: getErrorDetail(error) },
);
},
});
}}
/>
</>
);
};
type OAuth2AppSecretsTableProps = {
secrets?: readonly TypesGen.OAuth2ProviderAppSecret[];
generateAppSecret: () => void;
isLoadingSecrets: boolean;
mutatingResource: MutatingResource;
deleteAppSecret: (id: string) => void;
type EndpointFieldProps = {
label: string;
value: string;
};
const OAuth2AppSecretsTable: FC<OAuth2AppSecretsTableProps> = ({
secrets,
generateAppSecret,
isLoadingSecrets,
mutatingResource,
deleteAppSecret,
}) => {
const EndpointField: FC<EndpointFieldProps> = ({ label, value }) => {
return (
<>
<div className="flex flex-row gap-4 items-baseline justify-between">
<h2>Client secrets</h2>
<Button
disabled={mutatingResource.createSecret}
type="submit"
onClick={generateAppSecret}
>
<Spinner loading={mutatingResource.createSecret} />
Generate secret
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80%]">Secret</TableHead>
<TableHead className="w-[20%]">Last Used</TableHead>
<TableHead className="w-[1%]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoadingSecrets && <TableLoader />}
{!isLoadingSecrets && (!secrets || secrets.length === 0) && (
<TableRow>
<TableCell colSpan={999}>
<div className="text-center">
No client secrets have been generated.
</div>
</TableCell>
</TableRow>
)}
{!isLoadingSecrets &&
secrets?.map((secret) => (
<OAuth2SecretRow
key={secret.id}
secret={secret}
mutatingResource={mutatingResource}
deleteAppSecret={deleteAppSecret}
/>
))}
</TableBody>
</Table>
</>
<div className="flex items-center gap-2">
<dt className="text-sm">{label}</dt>
<dd className="m-0">
<div className="flex items-center gap-0.5">
<code className="w-fit rounded-md bg-surface-secondary px-2 py-0.5 font-mono text-xs text-content-secondary">
{value}
</code>
<CopyButton
text={value}
label={`Copy ${label}`}
size="icon"
variant="subtle"
/>
</div>
</dd>
</div>
);
};
type OAuth2SecretRowProps = {
secret: TypesGen.OAuth2ProviderAppSecret;
deleteAppSecret: (id: string) => void;
mutatingResource: MutatingResource;
onDelete: (id: string) => void;
isDeleting: boolean;
};
const OAuth2SecretRow: FC<OAuth2SecretRowProps> = ({
secret,
deleteAppSecret,
mutatingResource,
onDelete,
isDeleting,
}) => {
const [showDelete, setShowDelete] = useState<boolean>(false);
const [showDelete, setShowDelete] = useState(false);
return (
<TableRow key={secret.id} data-testid={`secret-${secret.id}`}>
<TableRow data-testid={`secret-${secret.id}`}>
<TableCell>*****{secret.client_secret_truncated}</TableCell>
<TableCell data-pixel="ignore">
{secret.last_used_at ? createDayString(secret.last_used_at) : "never"}
{secret.last_used_at ? createDayString(secret.last_used_at) : "Never"}
</TableCell>
<TableCell>
<ConfirmDialog
type="delete"
hideCancel={false}
open={showDelete}
onConfirm={() => deleteAppSecret(secret.id)}
onConfirm={() => {
onDelete(secret.id);
setShowDelete(false);
}}
onClose={() => setShowDelete(false)}
title="Delete OAuth2 client secret"
confirmLoading={mutatingResource.deleteSecret}
confirmLoading={isDeleting}
confirmText="Delete"
description={
<>
@@ -303,7 +429,7 @@ const OAuth2SecretRow: FC<OAuth2SecretRowProps> = ({
}
/>
<Button variant="destructive" onClick={() => setShowDelete(true)}>
Delete&hellip;
Delete secret
</Button>
</TableCell>
</TableRow>
@@ -1,79 +1,189 @@
import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import { TriangleAlertIcon } from "lucide-react";
import { type FC, useEffect, useRef } from "react";
import { Link } from "react-router";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Form, FormFields } from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { getFormHelpers } from "#/utils/formUtils";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField";
import {
getFormHelpers,
iconValidator,
nameValidator,
onChangeTrimmed,
} from "#/utils/formUtils";
type OAuth2AppFormValues = {
name: string;
callback_url: string;
icon: string;
};
type OAuth2AppFormProps = {
app?: TypesGen.OAuth2ProviderApp;
onSubmit: (data: TypesGen.PostOAuth2ProviderAppRequest) => void;
onSubmit: (data: OAuth2AppFormValues) => void | Promise<void>;
error?: unknown;
isUpdating: boolean;
actions?: ReactNode;
defaultValues?: TypesGen.PostOAuth2ProviderAppRequest;
defaultValues?: OAuth2AppFormValues;
disabled: boolean;
onIconChange?: (icon: string) => void;
};
const BACK_HREF = "/deployment/oauth2-provider/apps";
const isHttpUrl = (value: string | undefined): boolean => {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
};
const validationSchema = Yup.object({
name: nameValidator("Name"),
callback_url: Yup.string()
.trim()
.required("Please enter a callback URL.")
.test("http-url", "Callback URL must be a valid URL.", (value) =>
isHttpUrl(value),
),
icon: iconValidator,
});
export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
app,
onSubmit,
error,
isUpdating,
actions,
defaultValues,
disabled,
onIconChange,
}) => {
const form = useFormik<TypesGen.PostOAuth2ProviderAppRequest>({
const didSubmit = useRef(false);
const form = useFormik<OAuth2AppFormValues>({
initialValues: {
name: app?.name ?? defaultValues?.name ?? "",
callback_url: app?.callback_url ?? defaultValues?.callback_url ?? "",
icon: app?.icon ?? defaultValues?.icon ?? "",
},
// Mark fields touched from the start so server-side validation errors
// surface as soon as they arrive instead of waiting for the user to
// interact with each field.
initialTouched: { name: true, callback_url: true, icon: true },
onSubmit,
validationSchema,
validateOnMount: true,
onSubmit: async (values) => {
didSubmit.current = true;
await onSubmit(values);
},
});
const getFieldHelpers = getFormHelpers(form, error);
const iconField = getFieldHelpers("icon");
const formDisabled = disabled || isUpdating;
const editing = Boolean(app);
const submitDisabled =
formDisabled || !form.isValid || (editing && !form.dirty);
// When the parent's mutation finishes without an error, treat the just-
// submitted values as the new baseline so the unsaved-changes prompt does
// not fire on subsequent navigations.
const previousIsUpdating = useRef(isUpdating);
useEffect(() => {
if (previousIsUpdating.current && !isUpdating) {
if (didSubmit.current && !error) {
form.resetForm({ values: form.values });
}
didSubmit.current = false;
}
previousIsUpdating.current = isUpdating;
}, [isUpdating, error, form]);
const unsavedChanges = useUnsavedChangesPrompt(
form.dirty && !form.isSubmitting,
);
return (
<form className="mt-2.5" onSubmit={form.handleSubmit}>
<div className="flex flex-col gap-5">
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(error) && <ErrorAlert error={error} />}
<FormField
field={getFieldHelpers("name", {
helperText: "The name of your Coder app.",
})}
label="Application name"
disabled={disabled}
field={getFieldHelpers("name")}
label="Name"
description="The name of your Coder app."
disabled={formDisabled}
onChange={onChangeTrimmed(form)}
autoFocus
required
/>
<FormField
field={getFieldHelpers("callback_url", {
helperText:
"The full URL to redirect to after a user authorizes an installation.",
})}
field={getFieldHelpers("callback_url")}
label="Callback URL"
disabled={disabled}
/>
<FormField
field={getFieldHelpers("icon", {
helperText: "A full or relative URL to an icon.",
})}
label="Application icon"
disabled={disabled}
description="The full URL to redirect to after a user authorizes an installation."
disabled={formDisabled}
required
/>
<div className="flex flex-col gap-2">
<Label htmlFor="icon">Icon</Label>
<div className="text-xs text-content-secondary">
Optional. URL or emoji shown for this application.
</div>
<IconPickerField
id="icon"
value={form.values.icon}
disabled={formDisabled}
onChange={(value) => {
void form.setFieldValue("icon", value);
void form.setFieldTouched("icon", true);
onIconChange?.(value);
}}
/>
{iconField.error ? (
<span className="text-xs text-content-destructive">
{iconField.helperText}
</span>
) : (
iconField.helperText && (
<span className="text-xs text-content-secondary">
{iconField.helperText}
</span>
)
)}
</div>
<div className="flex flex-row gap-4">
<Button disabled={isUpdating || disabled} type="submit">
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link to={BACK_HREF}>Cancel</Link>
</Button>
<Button disabled={submitDisabled} type="submit">
<Spinner loading={isUpdating} />
{app ? "Update application" : "Create application"}
</Button>
{actions}
</div>
</div>
</form>
</FormFields>
<ConfirmDialog
type="info"
hideCancel={false}
open={unsavedChanges.isOpen}
onClose={unsavedChanges.onCancel}
onConfirm={unsavedChanges.onConfirm}
title="Unsaved changes"
confirmText="Confirm"
description={
<div className="flex items-start gap-3">
<TriangleAlertIcon className="size-icon-sm mt-1 shrink-0" />
<p className="m-0">
Your updates haven't been saved. Leave anyway?
</p>
</div>
}
/>
</Form>
);
};
@@ -13,7 +13,7 @@ const OAuth2AppsSettingsPage: FC = () => {
return (
<>
<title>{pageTitle("OAuth2 Applications")}</title>
<title>{pageTitle("OAuth2 applications")}</title>
<OAuth2AppsSettingsPageView
apps={appsQuery.data}
@@ -19,6 +19,7 @@ import {
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
@@ -29,6 +30,15 @@ type OAuth2AppsSettingsProps = {
canCreateApp: boolean;
};
const AddApplicationButton: FC = () => (
<Button variant="outline" asChild>
<Link to="/deployment/oauth2-provider/apps/add">
<PlusIcon />
<span>Add application</span>
</Link>
</Button>
);
const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
apps,
isLoading,
@@ -36,53 +46,47 @@ const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
canCreateApp,
}) => {
return (
<>
<div className="flex flex-row gap-4 items-baseline justify-between">
<div>
<SettingsHeader>
<SettingsHeaderTitle>OAuth2 Applications</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure applications to use Coder as an OAuth2 provider.
</SettingsHeaderDescription>
</SettingsHeader>
<div>
<SettingsHeader
actions={canCreateApp ? <AddApplicationButton /> : undefined}
>
<SettingsHeaderTitle>OAuth2 applications</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure applications to use Coder as an OAuth2 provider.
</SettingsHeaderDescription>
</SettingsHeader>
{Boolean(error) && (
<div className="mb-4">
<ErrorAlert error={error} />
</div>
)}
{canCreateApp && (
<Button variant="outline" asChild>
<Link to="/deployment/oauth2-provider/apps/add">
<PlusIcon />
Add application
</Link>
</Button>
)}
</div>
{error && <ErrorAlert error={error} />}
<Table className="mt-8">
<Table className="table-fixed" aria-label="OAuth2 applications">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-[1%]" />
<TableHead className="w-1/3">Name</TableHead>
<TableHead className="w-1/3">Callback URL</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && <TableLoader />}
{apps?.map((app) => (
<OAuth2AppRow key={app.id} app={app} />
))}
{apps?.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<div className="text-center">
No OAuth2 applications have been configured.
</div>
</TableCell>
</TableRow>
<TableBody size="lg">
{isLoading ? (
<TableLoader />
) : !error && (!apps || apps.length === 0) ? (
<TableEmpty
message="No OAuth2 applications configured"
description="Add an application to use Coder as an OAuth2 provider."
cta={canCreateApp ? <AddApplicationButton /> : undefined}
/>
) : (
apps?.map((app) => <OAuth2AppRow key={app.id} app={app} />)
)}
</TableBody>
</Table>
</>
</div>
);
};
@@ -97,17 +101,34 @@ const OAuth2AppRow: FC<OAuth2AppRowProps> = ({ app }) => {
});
return (
<TableRow key={app.id} data-testid={`app-${app.id}`} {...clickableProps}>
<TableCell>
<TableRow data-testid={`app-${app.id}`} {...clickableProps}>
<TableCell className="min-w-0 px-4 py-3">
<AvatarData
avatar={<Avatar variant="icon" src={app.icon} fallback={app.name} />}
avatar={
<Avatar
variant="icon"
size="lg"
src={app.icon}
fallback={app.name}
/>
}
title={app.name}
/>
</TableCell>
<TableCell>
<div className="flex pl-4">
<ChevronRightIcon className="size-icon-sm" />
<TableCell className="min-w-0">
<span
className="block truncate text-content-secondary"
title={app.callback_url}
>
{app.callback_url}
</span>
</TableCell>
<TableCell className="w-10 text-center">
<div className="flex justify-end items-center pr-4">
<ChevronRightIcon
aria-hidden
className="size-icon-md text-content-primary flex-shrink-0"
/>
</div>
</TableCell>
</TableRow>
+6 -6
View File
@@ -4856,13 +4856,13 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [
{
id: "1",
name: "foo",
callback_url: "http://localhost:3001",
callback_url: "http://127.0.0.1:3001",
icon: "/icon/github.svg",
endpoints: {
authorization: "http://localhost:3001/oauth2/authorize",
token: "http://localhost:3001/oauth2/token",
authorization: "http://127.0.0.1:3001/oauth2/authorize",
token: "http://127.0.0.1:3001/oauth2/token",
device_authorization: "",
token_revoke: "http://localhost:3001/oauth2/revoke",
token_revoke: "http://127.0.0.1:3001/oauth2/revoke",
},
},
];
@@ -4875,9 +4875,9 @@ export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] =
last_used_at: null,
},
{
id: "1",
id: "2",
last_used_at: "2022-12-16T20:10:45.637452Z",
client_secret_truncated: "foo",
client_secret_truncated: "bar",
},
];