8000 feat: add OAuth2 user settings page (#12237) · coder/coder@51d178d · GitHub
[go: up one dir, main page]

Skip to content

Commit 51d178d

Browse files
authored
feat: add OAuth2 user settings page (#12237)
1 parent 3cbe14f commit 51d178d

File tree

6 files changed

+228
-9
lines changed

6 files changed

+228
-9
lines changed

site/src/AppRouter.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ const ExternalAuthPage = lazy(
156156
const UserExternalAuthSettingsPage = lazy(
157157
() => import("./pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage"),
158158
);
159+
const UserOAuth2ProviderSettingsPage = lazy(
160+
() =>
161+
import("./pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage"),
162+
);
159163
const TemplateVersionPage = lazy(
160164
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
161165
);
@@ -362,6 +366,10 @@ export const AppRouter: FC = () => {
362366
path="external-auth"
363367
element={<UserExternalAuthSettingsPage />}
364368
/>
369+
<Route
370+
path="oauth2-provider"
371+
element={<UserOAuth2ProviderSettingsPage />}
372+
/>
365373
<Route path="tokens">
366374
<Route index element={<TokensPage />} />
367375
<Route path="new" element={<CreateTokenPage />} />

site/src/api/api.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -974,10 +974,13 @@ export const unlinkExternalAuthProvider = async (
974974
return resp.data;
975975
};
976976

977-
export const getOAuth2ProviderApps = async (): Promise<
978-
TypesGen.OAuth2ProviderApp[]
979-
> => {
980-
const resp = await axios.get(`/api/v2/oauth2-provider/apps`);
977+
export const getOAuth2ProviderApps = async (
978+
filter?: TypesGen.OAuth2ProviderAppFilter,
979+
): Promise<TypesGen.OAuth2ProviderApp[]> => {
980+
const params = filter?.user_id
981+
? new URLSearchParams({ user_id: filter.user_id })
982+
: "";
983+
const resp = await axios.get(`/api/v2/oauth2-provider/apps?${params}`);
981984
return resp.data;
982985
};
983986

@@ -1002,6 +1005,7 @@ export const putOAuth2ProviderApp = async (
10021005
const response = await axios.put(`/api/v2/oauth2-provider/apps/${id}`, data);
10031006
return response.data;
10041007
};
1008+
10051009
export const deleteOAuth2ProviderApp = async (id: string): Promise<void> => {
10061010
await axios.delete(`/api/v2/oauth2-provider/apps/${id}`);
10071011
};
@@ -1029,6 +1033,10 @@ export const deleteOAuth2ProviderAppSecret = async (
10291033
);
10301034
};
10311035

1036+
export const revokeOAuth2ProviderApp = async (appId: string): Promise<void> => {
1037+
await axios.delete(`/oauth2/tokens?client_id=${appId}`);
1038+
};
1039+
10321040
export const getAuditLogs = async (
10331041
options: TypesGen.AuditLogsRequest,
10341042
): Promise<TypesGen.AuditLogResponse> => {

site/src/api/queries/oauth2.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import * as API from "api/api";
33
import type * as TypesGen from "api/typesGenerated";
44

55
const appsKey = ["oauth2-provider", "apps"];
6-
const appKey = (id: string) => appsKey.concat(id);
7-
const appSecretsKey = (id: string) => appKey(id).concat("secrets");
6+
const userAppsKey = (userId: string) => appsKey.concat(userId);
7+
const appKey = (appId: string) => appsKey.concat(appId);
8+
const appSecretsKey = (appId: string) => appKey(appId).concat("secrets");
89

9-
export const getApps = () => {
10+
export const getApps = (userId?: string) => {
1011
return {
11-
queryKey: appsKey,
12-
queryFn: () => API.getOAuth2ProviderApps(),
12+
queryKey: userId ? appsKey.concat(userId) : appsKey,
13+
queryFn: () => API.getOAuth2ProviderApps({ user_id: userId }),
1314
};
1415
};
1516

@@ -91,3 +92,14 @@ export const deleteAppSecret = (queryClient: QueryClient) => {
9192
},
9293
};
9394
};
95+
96+
export const revokeApp = (queryClient: QueryClient, userId: string) => {
97+
return {
98+
mutationFn: API.revokeOAuth2ProviderApp,
99+
onSuccess: async () => {
100+
await queryClient.invalidateQueries({
101+
queryKey: userAppsKey(userId),
102+
});
103+
},
104+
};
105+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { type FC, useState } from "react";
2+
import { useMutation, useQuery, useQueryClient } from "react-query";
3+
import { getErrorMessage } from "api/errors";
4+
import { getApps, revokeApp } from "api/queries/oauth2";
5+
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
6+
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
7+
import { useMe } from "contexts/auth/useMe";
8+
import { Section } from "../Section";
9+
import OAuth2ProviderPageView from "./OAuth2ProviderPageView";
10+
11+
const OAuth2ProviderPage: FC = () => {
12+
const me = useMe();
13+
const queryClient = useQueryClient();
14+
const userOAuth2AppsQuery = useQuery(getApps(me.id));
15+
const revokeAppMutation = useMutation(revokeApp(queryClient, me.id));
16+
const [appIdToRevoke, setAppIdToRevoke] = useState<string>();
17+
const appToRevoke = userOAuth2AppsQuery.data?.find(
18+
(app) => app.id === appIdToRevoke,
19+
);
20+
21+
return (
22+
<Section title="OAuth2 Applications" layout="fluid">
23+
<OAuth2ProviderPageView
24+
isLoading={userOAuth2AppsQuery.isLoading}
25+
error={userOAuth2AppsQuery.error}
26+
apps={userOAuth2AppsQuery.data}
27+
revoke={(app) => {
28+
setAppIdToRevoke(app.id);
29+
}}
30+
/>
31+
{appToRevoke !== undefined && (
32+
<DeleteDialog
33+
title="Revoke Application"
34+
verb="Revoking"
35+
info={`This will invalidate any tokens created by the OAuth2 application "${appToRevoke.name}".`}
36+
label="Name of the application to revoke"
37+
isOpen
38+
confirmLoading={revokeAppMutation.isLoading}
39+
name={appToRevoke.name}
40+
entity="application"
41+
onCancel={() => setAppIdToRevoke(undefined)}
42+
onConfirm={async () => {
43+
try {
44+
await revokeAppMutation.mutateAsync(appToRevoke.id);
45+
displaySuccess(
46+
`You have successfully revoked the OAuth2 application "${appToRevoke.name}"`,
47+
);
48+
setAppIdToRevoke(undefined);
49+
} catch (error) {
50+
displayError(
51+
getErrorMessage(error, "Failed to revoke application."),
52+
);
53+
}
54+
}}
55+
/>
56+
)}
57+
</Section>
58+
);
59+
};
60+
61+
export default OAuth2ProviderPage;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { MockOAuth2ProviderApps } from "testHelpers/entities";
3+
import OAuth2ProviderPageView from "./OAuth2ProviderPageView";
4+
5+
const meta: Meta<typeof OAuth2ProviderPageView> = {
6+
title: "pages/UserSettingsPage/OAuth2ProviderPageView",
7+
component: OAuth2ProviderPageView,
8+
};
9+
10+
export default meta;
11+
type Story = StoryObj<typeof OAuth2ProviderPageView>;
12+
13+
export const Loading: Story = {
14+
args: {
15+
isLoading: true,
16+
error: undefined,
17+
revoke: () => undefined,
18+
},
19+
};
20+
21+
export const Error: Story = {
22+
args: {
23+
isLoading: false,
24+
error: "some error",
25+
revoke: () => undefined,
26+
},
27+
};
28+
29+
export const Apps: Story = {
30+
args: {
31+
isLoading: false,
32+
error: undefined,
33+
apps: MockOAuth2ProviderApps,
34+
revoke: () => undefined,
35+
},
36+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Button from "@mui/material/Button";
2+
import Table from "@mui/material/Table";
3+
import TableBody from "@mui/material/TableBody";
4+
import TableCell from "@mui/material/TableCell";
5+
import TableContainer from "@mui/material/TableContainer";
6+
import TableHead from "@mui/material/TableHead";
7+
import TableRow from "@mui/material/TableRow";
8+
import { type FC } from "react";
9+
import type * as TypesGen from "api/typesGenerated";
10+
import { AvatarData } from "components/AvatarData/AvatarData";
11+
import { Avatar } from "components/Avatar/Avatar";
12+
import { ErrorAlert } from "components/Alert/ErrorAlert";
13+
import { TableLoader } from "components/TableLoader/TableLoader";
14+
15+
export type OAuth2ProviderPageViewProps = {
16+
isLoading: boolean;
17+
error: unknown;
18+
apps?: TypesGen.OAuth2ProviderApp[];
19+
revoke: (app: TypesGen.OAuth2ProviderApp) => void;
20+
};
21+
22+
const OAuth2ProviderPageView: FC<OAuth2ProviderPageViewProps> = ({
23+
isLoading,
24+
error,
25+
apps,
26+
revoke,
27+
}) => {
28+
return (
29+
<>
30+
{error && <ErrorAlert error={error} />}
31+
32+
<TableContainer>
33+
<Table>
34+
<TableHead>
35+
<TableRow>
36+
<TableCell width="100%">Name</TableCell>
37+
<TableCell width="1%" />
38+
</TableRow>
39+
</TableHead>
40+
<TableBody>
41+
{isLoading && <TableLoader />}
42+
{apps?.map((app) => (
43+
<OAuth2AppRow key={app.id} app={app} revoke={revoke} />
44+
))}
45+
{apps?.length === 0 && (
46+
<TableRow>
47+
<TableCell colSpan={999}>
48+
<div css={{ textAlign: "center" }}>
49+
No OAuth2 applications have been authorized.
50+
</div>
51+
</TableCell>
52+
</TableRow>
53+
)}
54+
</TableBody>
55+
</Table>
56+
</TableContainer>
57+
</>
58+
);
59+
};
60+
61+
type OAuth2AppRowProps = {
62+
app: TypesGen.OAuth2ProviderApp;
63+
revoke: (app: TypesGen.OAuth2ProviderApp) => void;
64+
};
65+
66+
const OAuth2AppRow: FC<OAuth2AppRowProps> = ({ app, revoke }) => {
67+
return (
68+
<TableRow key={app.id} data-testid={`app-${app.id}`}>
69+
<TableCell>
70+
<AvatarData
71+
title={app.name}
72+
avatar={
73+
Boolean(app.icon) && (
74+
<Avatar src={app.icon} variant="square" fitImage />
75+
)
76+
}
77+
/>
78+
</TableCell>
79+
80+
<TableCell>
81+
<Button
82+
variant="contained"
83+
size="small"
84+
color="error"
85+
onClick={() => revoke(app)}
86+
>
87+
Revoke&hellip;
88+
</Button>
89+
</TableCell>
90+
</TableRow>
91+
);
92+
};
93+
94+
export default OAuth2ProviderPageView;

0 commit comments

Comments
 (0)
0