feat: add OAuth2 user settings page (#12237)

This commit is contained in:
Asher
2024-02-21 14:16:55 -09:00
committed by GitHub
parent 3cbe14fdad
commit 51d178d538
6 changed files with 228 additions and 9 deletions
+8
View File
@@ -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={<UserExternalAuthSettingsPage />}
/>
<Route
path="oauth2-provider"
element={<UserOAuth2ProviderSettingsPage />}
/>
<Route path="tokens">
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
+12 -4
View File
@@ -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<TypesGen.OAuth2ProviderApp[]> => {
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<void> => {
await axios.delete(`/api/v2/oauth2-provider/apps/${id}`);
};
@@ -1029,6 +1033,10 @@ export const deleteOAuth2ProviderAppSecret = async (
);
};
export const revokeOAuth2ProviderApp = async (appId: string): Promise<void> => {
await axios.delete(`/oauth2/tokens?client_id=${appId}`);
};
export const getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {
+17 -5
View File
@@ -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),
});
},
};
};
@@ -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<string>();
const appToRevoke = userOAuth2AppsQuery.data?.find(
(app) => app.id === appIdToRevoke,
);
return (
<Section title="OAuth2 Applications" layout="fluid">
<OAuth2ProviderPageView
isLoading={userOAuth2AppsQuery.isLoading}
error={userOAuth2AppsQuery.error}
apps={userOAuth2AppsQuery.data}
revoke={(app) => {
setAppIdToRevoke(app.id);
}}
/>
{appToRevoke !== undefined && (
<DeleteDialog
title="Revoke Application"
verb="Revoking"
info={`This will invalidate any tokens created by the OAuth2 application "${appToRevoke.name}".`}
label="Name of the application to revoke"
isOpen
confirmLoading={revokeAppMutation.isLoading}
name={appToRevoke.name}
entity="application"
onCancel={() => 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."),
);
}
}}
/>
)}
</Section>
);
};
export default OAuth2ProviderPage;
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockOAuth2ProviderApps } from "testHelpers/entities";
import OAuth2ProviderPageView from "./OAuth2ProviderPageView";
const meta: Meta<typeof OAuth2ProviderPageView> = {
title: "pages/UserSettingsPage/OAuth2ProviderPageView",
component: OAuth2ProviderPageView,
};
export default meta;
type Story = StoryObj<typeof OAuth2ProviderPageView>;
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,
},
};
@@ -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<OAuth2ProviderPageViewProps> = ({
isLoading,
error,
apps,
revoke,
}) => {
return (
<>
{error && <ErrorAlert error={error} />}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="100%">Name</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableLoader />}
{apps?.map((app) => (
<OAuth2AppRow key={app.id} app={app} revoke={revoke} />
))}
{apps?.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<div css={{ textAlign: "center" }}>
No OAuth2 applications have been authorized.
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</>
);
};
type OAuth2AppRowProps = {
app: TypesGen.OAuth2ProviderApp;
revoke: (app: TypesGen.OAuth2ProviderApp) => void;
};
const OAuth2AppRow: FC<OAuth2AppRowProps> = ({ app, revoke }) => {
return (
<TableRow key={app.id} data-testid={`app-${app.id}`}>
<TableCell>
<AvatarData
title={app.name}
avatar={
Boolean(app.icon) && (
<Avatar src={app.icon} variant="square" fitImage />
)
}
/>
</TableCell>
<TableCell>
<Button
variant="contained"
size="small"
color="error"
onClick={() => revoke(app)}
>
Revoke&hellip;
</Button>
</TableCell>
</TableRow>
);
};
export default OAuth2ProviderPageView;