Skip to content

Commit d865c7f

Browse files
committed
Add frontend for managing OAuth2 applications
1 parent 3c096b2 commit d865c7f

15 files changed

+969
-0
lines changed

site/src/AppRouter.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,24 @@ const ExternalAuthSettingsPage = lazy(
117117
"./pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage"
118118
),
119119
);
120+
const OAuth2AppsSettingsPage = lazy(
121+
() =>
122+
import(
123+
"./pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage"
124+
),
125+
);
126+
const EditOAuth2AppPage = lazy(
127+
() =>
128+
import(
129+
"./pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage"
130+
),
131+
);
132+
const CreateOAuth2AppPage = lazy(
133+
() =>
134+
import(
135+
"./pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage"
136+
),
137+
);
120138
const NetworkSettingsPage = lazy(
121139
() =>
122140
import(
@@ -313,6 +331,13 @@ export const AppRouter: FC = () => {
313331
path="external-auth"
314332
element={<ExternalAuthSettingsPage />}
315333
/>
334+
335+
<Route path="oauth2-apps">
336+
<Route index element={<OAuth2AppsSettingsPage />} />
337+
<Route path="add" element={<CreateOAuth2AppPage />} />
338+
<Route path=":appId" element={<EditOAuth2AppPage />} />
339+
</Route>
340+
316341
<Route
317342
path="workspace-proxies"
318343
element={<WorkspaceProxyPage />}

site/src/api/api.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,55 @@ export const unlinkExternalAuthProvider = async (
952952
return resp.data;
953953
};
954954

955+
export const getOAuth2Apps = async (): Promise<TypesGen.OAuth2App[]> => {
956+
const resp = await axios.get(`/api/v2/oauth2/apps`);
957+
return resp.data;
958+
};
959+
960+
export const getOAuth2App = async (id: string): Promise<TypesGen.OAuth2App> => {
961+
const resp = await axios.get(`/api/v2/oauth2/apps/${id}`);
962+
return resp.data;
963+
};
964+
965+
export const postOAuth2App = async (
966+
data: TypesGen.PostOAuth2AppRequest,
967+
): Promise<TypesGen.OAuth2App> => {
968+
const response = await axios.post(`/api/v2/oauth2/apps`, data);
969+
return response.data;
970+
};
971+
972+
export const putOAuth2App = async (
973+
id: string,
974+
data: TypesGen.PutOAuth2AppRequest,
975+
): Promise<TypesGen.OAuth2App> => {
976+
const response = await axios.put(`/api/v2/oauth2/apps/${id}`, data);
977+
return response.data;
978+
};
979+
export const deleteOAuth2App = async (id: string): Promise<void> => {
980+
await axios.delete(`/api/v2/oauth2/apps/${id}`);
981+
};
982+
983+
export const getOAuth2AppSecrets = async (
984+
id: string,
985+
): Promise<TypesGen.OAuth2AppSecret[]> => {
986+
const resp = await axios.get(`/api/v2/oauth2/apps/${id}/secrets`);
987+
return resp.data;
988+
};
989+
990+
export const postOAuth2AppSecret = async (
991+
id: string,
992+
): Promise<TypesGen.OAuth2AppSecretFull> => {
993+
const resp = await axios.post(`/api/v2/oauth2/apps/${id}/secrets`);
994+
return resp.data;
995+
};
996+
997+
export const deleteOAuth2AppSecret = async (
998+
appId: string,
999+
secretId: string,
1000+
): Promise<void> => {
1001+
await axios.delete(`/api/v2/oauth2/apps/${appId}/secrets/${secretId}`);
1002+
};
1003+
9551004
export const getAuditLogs = async (
9561005
options: TypesGen.AuditLogsRequest,
9571006
): Promise<TypesGen.AuditLogResponse> => {

site/src/api/queries/oauth2.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as API from "api/api";
2+
3+
export const oauth2AppsKey = ["oauth-apps"];
4+
5+
export const oauth2Apps = () => {
6+
return {
7+
queryKey: oauth2AppsKey,
8+
queryFn: () => API.getOAuth2Apps(),
9+
};
10+
};
11+
12+
export const oauth2App = (id: string) => {
13+
return {
14+
queryKey: [oauth2AppsKey, id],
15+
queryFn: () => API.getOAuth2App(id),
16+
};
17+
};
18+
19+
export const oauth2AppSecretsKey = ["oauth-app-secrets"];
20+
21+
export const oauth2AppSecrets = (id: string) => {
22+
return {
23+
queryKey: [oauth2AppSecretsKey, id],
24+
queryFn: () => API.getOAuth2AppSecrets(id),
25+
};
26+
};

site/src/components/DeploySettingsLayout/Sidebar.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Globe from "@mui/icons-material/PublicOutlined";
77
import HubOutlinedIcon from "@mui/icons-material/HubOutlined";
88
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined";
99
import MonitorHeartOutlined from "@mui/icons-material/MonitorHeartOutlined";
10+
// import Token from "@mui/icons-material/Token";
1011
import { type FC } from "react";
1112
import { useDashboard } from "components/Dashboard/DashboardProvider";
1213
import { GitIcon } from "components/Icons/GitIcon";
@@ -35,6 +36,10 @@ export const Sidebar: FC = () => {
3536
<SidebarNavItem href="external-auth" icon={GitIcon}>
3637
External Authentication
3738
</SidebarNavItem>
39+
{/* Not exposing this yet since token exchange is not finished yet.
40+
<SidebarNavItem href="oauth2-apps" icon={Token}>
41+
OAuth2 Applications
42+
</SidebarNavItem>*/}
3843
<SidebarNavItem href="network" icon={Globe}>
3944
Network
4045
</SidebarNavItem>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useMutation } from "react-query";
2+
import { postOAuth2App } from "api/api";
3+
import type * as TypesGen from "api/typesGenerated";
4+
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
5+
import { FC } from "react";
6+
import { useNavigate } from "react-router-dom";
7+
import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
8+
import { pageTitle } from "utils/page";
9+
import { Helmet } from "react-helmet-async";
10+
11+
const CreateOAuth2AppPage: FC = () => {
12+
const navigate = useNavigate();
13+
14+
const postMutation = useMutation({
15+
mutationFn: postOAuth2App,
16+
onSuccess: (newApp: TypesGen.OAuth2App) => {
17+
displaySuccess(
18+
`Successfully added the OAuth2 application "${newApp.name}".`,
19+
);
20+
navigate(`/deployment/oauth2-apps/${newApp.id}?created=true`);
21+
},
22+
onError: () => displayError("Failed to create OAuth2 application"),
23+
});
24+
25+
return (
26+
<>
27+
<Helmet>
28+
<title>{pageTitle("New OAuth2 Application")}</title>
29+
</Helmet>
30+
31+
<CreateOAuth2AppPageView
32+
isUpdating={postMutation.isLoading}
33+
error={postMutation.error}
34+
createApp={(req: TypesGen.PostOAuth2AppRequest) => {
35+
postMutation.mutate(req);
36+
}}
37+
/>
38+
</>
39+
);
40+
};
41+
42+
export default CreateOAuth2AppPage;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
2+
3+
export default {
4+
title: "pages/DeploySettingsPage/CreateOAuth2AppPageView",
5+
component: CreateOAuth2AppPageView,
6+
};
7+
8+
export const Loading = {
9+
args: {
10+
isLoading: true,
11+
},
12+
};
13+
14+
export const Default = {
15+
args: {
16+
isLoading: false,
17+
},
18+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Button from "@mui/material/Button";
2+
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
3+
import { type FC } from "react";
4+
import { Link } from "react-router-dom";
5+
import type * as TypesGen from "api/typesGenerated";
6+
import { ErrorAlert } from "components/Alert/ErrorAlert";
7+
import { Header } from "components/DeploySettingsLayout/Header";
8+
import { Stack } from "components/Stack/Stack";
9+
import { OAuth2AppForm } from "./OAuth2AppForm";
10+
11+
type CreateOAuth2AppProps = {
12+
isUpdating: boolean;
13+
createApp: (req: TypesGen.PostOAuth2AppRequest) => void;
14+
error?: unknown;
15+
};
16+
17+
export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
18+
isUpdating,
19+
createApp,
20+
error,
21+
}) => {
22+
return (
23+
<>
24+
<Stack
25+
alignItems="baseline"
26+
direction="row"
27+
justifyContent="space-between"
28+
>
29+
<Header
30+
title="Add an OAuth2 application"
31+
description="Configure an application to use Coder as an OAuth2 provider."
32+
/>
33+
<Button
34+
component={Link}
35+
startIcon={<KeyboardArrowLeft />}
36+
to="/deployment/oauth2-apps"
37+
>
38+
All OAuth2 Applications
39+
</Button>
40+
</Stack>
41+
42+
<Stack>
43+
{error ? <ErrorAlert error={error} /> : undefined}
44+
<OAuth2AppForm
45+
onSubmit={createApp}
46+
isUpdating={isUpdating}
47+
error={error}
48+
/>
49+
</Stack>
50+
</>
51+
);
52+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useMutation, useQuery, useQueryClient } from "react-query";
2+
import {
3+
deleteOAuth2App,
4+
deleteOAuth2AppSecret,
5+
postOAuth2AppSecret,
6+
putOAuth2App,
7+
} from "api/api";
8+
import type * as TypesGen from "api/typesGenerated";
9+
import {
10+
oauth2App,
11+
oauth2AppSecrets,
12+
oauth2AppSecretsKey,
13+
} from "api/queries/oauth2";
14+
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
15+
import { FC, useState } from "react";
16+
import { useNavigate, useParams } from "react-router-dom";
17+
import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
18+
import { pageTitle } from "utils/page";
19+
import { Helmet } from "react-helmet-async";
20+
21+
const EditOAuth2AppPage: FC = () => {
22+
const navigate = useNavigate();
23+
const { appId } = useParams() as { appId: string };
24+
const queryClient = useQueryClient();
25+
const [newAppSecret, setNewAppSecret] = useState<
26+
TypesGen.OAuth2AppSecretFull | undefined
27+
>(undefined);
28+
29+
const oauth2AppQuery = useQuery(oauth2App(appId));
30+
const appName = oauth2AppQuery.data?.name;
31+
32+
const deleteMutation = useMutation({
33+
mutationFn: deleteOAuth2App,
34+
onSuccess: async () => {
35+
displaySuccess(
36+
`You have successfully deleted the OAuth2 application "${appName}"`,
37+
);
38+
navigate("/deployment/oauth2-apps?deleted=true");
39+
},
40+
onError: () => displayError("Failed to delete OAuth2 application"),
41+
});
42+
43+
const putMutation = useMutation({
44+
mutationFn: ({
45+
id,
46+
req,
47+
}: {
48+
id: string;
49+
req: TypesGen.PutOAuth2AppRequest;
50+
}) => putOAuth2App(id, req),
51+
onSuccess: () => {
52+
displaySuccess(
53+
`Successfully updated the OAuth2 application "${appName}".`,
54+
);
55+
navigate("/deployment/oauth2-apps?updated=true");
56+
},
57+
onError: () => displayError("Failed to update OAuth2 application"),
58+
});
59+
60+
const oauth2AppSecretsQuery = useQuery(oauth2AppSecrets(appId));
61+
62+
const postSecretMutation = useMutation({
63+
mutationFn: postOAuth2AppSecret,
64+
onSuccess: async (secret: TypesGen.OAuth2AppSecretFull) => {
65+
displaySuccess("Successfully generated OAuth2 client secret");
66+
setNewAppSecret(secret);
67+
await queryClient.invalidateQueries([oauth2AppSecretsKey, appId]);
68+
},
69+
onError: () => displayError("Failed to generate OAuth2 client secret"),
70+
});
71+
72+
const deleteSecretMutation = useMutation({
73+
mutationFn: ({ appId, secretId }: { appId: string; secretId: string }) =>
74+
deleteOAuth2AppSecret(appId, secretId),
75+
onSuccess: async () => {
76+
displaySuccess("Successfully deleted an OAuth2 client secret");
77+
await queryClient.invalidateQueries([oauth2AppSecretsKey, appId]);
78+
},
79+
onError: () => displayError("Failed to delete OAuth2 client secret"),
80+
});
81+
82+
return (
83+
<>
84+
<Helmet>
85+
<title>{pageTitle("Edit OAuth2 Application")}</title>
86+
</Helmet>
87+
88+
<EditOAuth2AppPageView
89+
app={oauth2AppQuery.data}
90+
secrets={oauth2AppSecretsQuery.data}
91+
isLoadingApp={oauth2AppQuery.isLoading}
92+
isLoadingSecrets={oauth2AppQuery.isLoading}
93+
isUpdating={
94+
putMutation.isLoading
95+
? "update-app"
96+
: deleteMutation.isLoading
97+
? "delete-app"
98+
: postSecretMutation.isLoading
99+
? "create-secret"
100+
: deleteSecretMutation.isLoading
101+
? "delete-secret"
102+
: false
103+
}
104+
newAppSecret={newAppSecret}
105+
dismissNewSecret={() => setNewAppSecret(undefined)}
106+
error={
107+
oauth2AppQuery.error ||
108+
putMutation.error ||
109+
deleteMutation.error ||
110+
oauth2AppSecretsQuery.error ||
111+
postSecretMutation.error ||
112+
deleteSecretMutation.error
113+
}
114+
updateApp={(req: TypesGen.PutOAuth2AppRequest) => {
115+
putMutation.mutate({ id: appId, req });
116+
}}
117+
deleteApp={() => {
118+
deleteMutation.mutate(appId);
119+
}}
120+
generateAppSecret={() => {
121+
postSecretMutation.mutate(appId);
122+
}}
123+
deleteAppSecret={(secretId: string) => {
124+
deleteSecretMutation.mutate({ appId, secretId });
125+
}}
126+
/>
127+
</>
128+
);
129+
};
130+
131+
export default EditOAuth2AppPage;

0 commit comments

Comments
 (0)