mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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
-55
@@ -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;
|
||||
|
||||
+95
-36
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+73
-50
@@ -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
-133
@@ -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;
|
||||
|
||||
+136
-70
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+341
-215
@@ -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…
|
||||
</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…
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const OAuth2AppsSettingsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle("OAuth2 Applications")}</title>
|
||||
<title>{pageTitle("OAuth2 applications")}</title>
|
||||
|
||||
<OAuth2AppsSettingsPageView
|
||||
apps={appsQuery.data}
|
||||
|
||||
+66
-45
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user