diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index c051ddacac833..ff4f1afb7d4b1 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -156,6 +156,10 @@ const ExternalAuthPage = lazy( const UserExternalAuthSettingsPage = lazy( () => import("./pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage"), ); +const UserOAuth2ProviderSettingsPage = lazy( + () => + import("./pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage"), +); const TemplateVersionPage = lazy( () => import("./pages/TemplateVersionPage/TemplateVersionPage"), ); @@ -362,6 +366,10 @@ export const AppRouter: FC = () => { path="external-auth" element={} /> + } + /> } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3a454e4389cd3..886b4d8f4cbc9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -974,10 +974,13 @@ export const unlinkExternalAuthProvider = async ( return resp.data; }; -export const getOAuth2ProviderApps = async (): Promise< - TypesGen.OAuth2ProviderApp[] -> => { - const resp = await axios.get(`/api/v2/oauth2-provider/apps`); +export const getOAuth2ProviderApps = async ( + filter?: TypesGen.OAuth2ProviderAppFilter, +): Promise => { + const params = filter?.user_id + ? new URLSearchParams({ user_id: filter.user_id }) + : ""; + const resp = await axios.get(`/api/v2/oauth2-provider/apps?${params}`); return resp.data; }; @@ -1002,6 +1005,7 @@ export const putOAuth2ProviderApp = async ( const response = await axios.put(`/api/v2/oauth2-provider/apps/${id}`, data); return response.data; }; + export const deleteOAuth2ProviderApp = async (id: string): Promise => { await axios.delete(`/api/v2/oauth2-provider/apps/${id}`); }; @@ -1029,6 +1033,10 @@ export const deleteOAuth2ProviderAppSecret = async ( ); }; +export const revokeOAuth2ProviderApp = async (appId: string): Promise => { + await axios.delete(`/oauth2/tokens?client_id=${appId}`); +}; + export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts index 849cbc5241625..78b31762b2aa5 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -3,13 +3,14 @@ import * as API from "api/api"; import type * as TypesGen from "api/typesGenerated"; const appsKey = ["oauth2-provider", "apps"]; -const appKey = (id: string) => appsKey.concat(id); -const appSecretsKey = (id: string) => appKey(id).concat("secrets"); +const userAppsKey = (userId: string) => appsKey.concat(userId); +const appKey = (appId: string) => appsKey.concat(appId); +const appSecretsKey = (appId: string) => appKey(appId).concat("secrets"); -export const getApps = () => { +export const getApps = (userId?: string) => { return { - queryKey: appsKey, - queryFn: () => API.getOAuth2ProviderApps(), + queryKey: userId ? appsKey.concat(userId) : appsKey, + queryFn: () => API.getOAuth2ProviderApps({ user_id: userId }), }; }; @@ -91,3 +92,14 @@ export const deleteAppSecret = (queryClient: QueryClient) => { }, }; }; + +export const revokeApp = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: API.revokeOAuth2ProviderApp, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userAppsKey(userId), + }); + }, + }; +}; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx new file mode 100644 index 0000000000000..f2b8f2a41694b --- /dev/null +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx @@ -0,0 +1,61 @@ +import { type FC, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { getErrorMessage } from "api/errors"; +import { getApps, revokeApp } from "api/queries/oauth2"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { useMe } from "contexts/auth/useMe"; +import { Section } from "../Section"; +import OAuth2ProviderPageView from "./OAuth2ProviderPageView"; + +const OAuth2ProviderPage: FC = () => { + const me = useMe(); + const queryClient = useQueryClient(); + const userOAuth2AppsQuery = useQuery(getApps(me.id)); + const revokeAppMutation = useMutation(revokeApp(queryClient, me.id)); + const [appIdToRevoke, setAppIdToRevoke] = useState(); + const appToRevoke = userOAuth2AppsQuery.data?.find( + (app) => app.id === appIdToRevoke, + ); + + return ( +
+ { + setAppIdToRevoke(app.id); + }} + /> + {appToRevoke !== undefined && ( + setAppIdToRevoke(undefined)} + onConfirm={async () => { + try { + await revokeAppMutation.mutateAsync(appToRevoke.id); + displaySuccess( + `You have successfully revoked the OAuth2 application "${appToRevoke.name}"`, + ); + setAppIdToRevoke(undefined); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to revoke application."), + ); + } + }} + /> + )} +
+ ); +}; + +export default OAuth2ProviderPage; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx new file mode 100644 index 0000000000000..c8086b444b2d0 --- /dev/null +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockOAuth2ProviderApps } from "testHelpers/entities"; +import OAuth2ProviderPageView from "./OAuth2ProviderPageView"; + +const meta: Meta = { + title: "pages/UserSettingsPage/OAuth2ProviderPageView", + component: OAuth2ProviderPageView, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: { + isLoading: true, + error: undefined, + revoke: () => undefined, + }, +}; + +export const Error: Story = { + args: { + isLoading: false, + error: "some error", + revoke: () => undefined, + }, +}; + +export const Apps: Story = { + args: { + isLoading: false, + error: undefined, + apps: MockOAuth2ProviderApps, + revoke: () => undefined, + }, +}; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx new file mode 100644 index 0000000000000..efa6d9f362274 --- /dev/null +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx @@ -0,0 +1,94 @@ +import Button from "@mui/material/Button"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import { type FC } from "react"; +import type * as TypesGen from "api/typesGenerated"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { Avatar } from "components/Avatar/Avatar"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { TableLoader } from "components/TableLoader/TableLoader"; + +export type OAuth2ProviderPageViewProps = { + isLoading: boolean; + error: unknown; + apps?: TypesGen.OAuth2ProviderApp[]; + revoke: (app: TypesGen.OAuth2ProviderApp) => void; +}; + +const OAuth2ProviderPageView: FC = ({ + isLoading, + error, + apps, + revoke, +}) => { + return ( + <> + {error && } + + + + + + Name + + + + + {isLoading && } + {apps?.map((app) => ( + + ))} + {apps?.length === 0 && ( + + +
+ No OAuth2 applications have been authorized. +
+
+
+ )} +
+
+
+ + ); +}; + +type OAuth2AppRowProps = { + app: TypesGen.OAuth2ProviderApp; + revoke: (app: TypesGen.OAuth2ProviderApp) => void; +}; + +const OAuth2AppRow: FC = ({ app, revoke }) => { + return ( + + + + ) + } + /> + + + + + + + ); +}; + +export default OAuth2ProviderPageView;