mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add OAuth2 user settings page (#12237)
This commit is contained in:
@@ -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
@@ -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> => {
|
||||
|
||||
@@ -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…
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth2ProviderPageView;
|
||||
Reference in New Issue
Block a user