Skip to content

Commit ca0bd42

Browse files
committed
Add user settings page for revoking OAuth2 apps
1 parent cae4e61 commit ca0bd42

File tree

6 files changed

+234
-9
lines changed

6 files changed

+234
-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(`/api/v2/oauth2-provider/apps/${appId}/tokens`);
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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
// This can happen if the app disappears from the query data but a user has
22+
// already started the revoke flow. It is safe to place this directly in the
23+
// render, because it does not run every single time.
24+
if (appToRevoke === undefined && typeof appIdToRevoke === "string") {
25+
setAppIdToRevoke(undefined);
26+
displayError("Application no longer exists.");
27+
}
28+
29+
return (
30+
<Section title="OAuth2 Applications" layout="fluid">
31+
<OAuth2ProviderPageView
32+
isLoading={userOAuth2AppsQuery.isLoading}
33+
error={userOAuth2AppsQuery.error}
34+
apps={userOAuth2AppsQuery.data}
35+
revoke={(app) => {
36+
setAppIdToRevoke(app.id);
37+
}}
38+
/>
39+
{appToRevoke !== undefined && (
40+
<DeleteDialog
41+
title="Revoke Application"
42+
verb="Revoking"
43+
info={`This will invalidate any tokens created by the OAuth2 application "${appToRevoke.name}".`}
44+
label="Name of the application to revoke"
45+
isOpen
46+
confirmLoading={revokeAppMutation.isLoading}
47+
name={appToRevoke.name}
48+
entity="application"
49+
onCancel={() => setAppIdToRevoke(undefined)}
50+
onConfirm={async () => {
51+
try {
52+
await revokeAppMutation.mutateAsync(appToRevoke.id);
53+
displaySuccess(
54+
`You have successfully revoked the OAuth2 application "${appToRevoke.name}"`,
55+
);
56+
setAppIdToRevoke(undefined);
57+
} catch (error) {
58+
displayError(
59+
getErrorMessage(error, "Failed to revoke application."),
60+
);
61+
}
62+
}}
63+
/>
64+
)}
65+
</Section>
66+
);
67+
};
68+
69+
export default OAuth2ProviderPage;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
revoke: () => undefined,
17+
},
18+
};
19+
20+
export const Error: Story = {
21+
args: {
22+
isLoading: false,
23+
error: "some error",
24+
revoke: () => undefined,
25+
},
26+
};
27+
28+
export const Apps: Story = {
29+
args: {
30+
isLoading: false,
31+
apps: MockOAuth2ProviderApps,
32+
revoke: () => undefined,
33+
},
34+
};
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)