Skip to content

Commit b9217dc

Browse files
committed
feat: add user/settings page for managing external auth
1 parent c02c754 commit b9217dc

File tree

8 files changed

+347
-8
lines changed

8 files changed

+347
-8
lines changed

site/src/AppRouter.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ const ObservabilitySettingsPage = lazy(
131131
const ExternalAuthPage = lazy(
132132
() => import("./pages/ExternalAuthPage/ExternalAuthPage"),
133133
);
134+
const UserExternalAuthSettingsPage = lazy(
135+
() =>
136+
import("./pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage"),
137+
);
134138
const TemplateVersionPage = lazy(
135139
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
136140
);
@@ -259,6 +263,10 @@ export const AppRouter: FC = () => {
259263
<Route path="versions">
260264
<Route path=":version">
261265
<Route index element={<TemplateVersionPage />} />
266+
<Route
267+
path="edit"
268+
element={<TemplateVersionEditorPage />}
269+
/>
262270
</Route>
263271
</Route>
264272
</Route>
@@ -314,6 +322,10 @@ export const AppRouter: FC = () => {
314322
<Route path="schedule" element={<SchedulePage />} />
315323
<Route path="security" element={<SecurityPage />} />
316324
<Route path="ssh-keys" element={<SSHKeysPage />} />
325+
<Route
326+
path="external-auth"
327+
element={<UserExternalAuthSettingsPage />}
328+
/>
317329
<Route path="tokens">
318330
<Route index element={<TokensPage />} />
319331
<Route path="new" element={<CreateTokenPage />} />
@@ -342,17 +354,13 @@ export const AppRouter: FC = () => {
342354
</Route>
343355
</Route>
344356

345-
{/* Pages that don't have the dashboard layout */}
357+
{/* Terminal and CLI auth pages don't have the dashboard layout */}
346358
<Route
347359
path="/:username/:workspace/terminal"
348360
element={<TerminalPage />}
349361
/>
350362
<Route path="/cli-auth" element={<CliAuthenticationPage />} />
351363
<Route path="/icons" element={<IconsPage />} />
352-
<Route
353-
path="/templates/:template/versions/:version/edit"
354-
element={<TemplateVersionEditorPage />}
355-
/>
356364
</Route>
357365

358366
{/* Using path="*"" means "match anything", so this route

site/src/api/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,19 @@ export const exchangeExternalAuthDevice = async (
937937
return resp.data;
938938
};
939939

940+
export const getUserExternalAuthProviders = async (
941+
): Promise<TypesGen.UserExternalAuthResponse> => {
942+
const resp = await axios.get(`/api/v2/external-auth`);
943+
return resp.data;
944+
};
945+
946+
export const unlinkExternalAuthProvider = async (
947+
provider: string,
948+
): Promise<string> => {
949+
const resp = await axios.delete(`/api/v2/external-auth/${provider}`);
950+
return resp.data;
951+
};
952+
940953
export const getAuditLogs = async (
941954
options: TypesGen.AuditLogsRequest,
942955
): Promise<TypesGen.AuditLogResponse> => {

site/src/api/queries/externalauth.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as API from "api/api";
2+
import { QueryClient } from "react-query";
3+
4+
const getUserExternalAuthsKey = () => ["list", "external-auth"];
5+
6+
// listUserExternalAuths returns all configured external auths for a given user.
7+
export const listUserExternalAuths = () => {
8+
return {
9+
queryKey: getUserExternalAuthsKey(),
10+
queryFn: () => API.getUserExternalAuthProviders(),
11+
};
12+
};
13+
14+
export const validateExternalAuth = (_: QueryClient) => {
15+
return {
16+
mutationFn: API.getExternalAuthProvider,
17+
onSuccess: async () => {
18+
// No invalidation needed.
19+
},
20+
};
21+
};
22+
23+
export const unlinkExternalAuths = (queryClient: QueryClient) => {
24+
return {
25+
mutationFn: API.unlinkExternalAuthProvider,
26+
onSuccess: async () => {
27+
await queryClient.invalidateQueries(["external-auth"]);
28+
},
29+
};
30+
};

site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export interface DeleteDialogProps {
1818
name: string;
1919
info?: string;
2020
confirmLoading?: boolean;
21+
verb?: string;
22+
title?: string;
23+
label?: string;
24+
confirmText?: string;
2125
}
2226

2327
export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
@@ -28,6 +32,11 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
2832
info,
2933
name,
3034
confirmLoading,
35+
// All optional to change the verbiage. For example, "unlinking" vs "deleting"
36+
verb,
37+
title,
38+
label,
39+
confirmText,
3140
}) => {
3241
const hookId = useId();
3342
const theme = useTheme();
@@ -52,14 +61,17 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
5261
type="delete"
5362
hideCancel={false}
5463
open={isOpen}
55-
title={`Delete ${entity}`}
64+
title={title ?? `Delete ${entity}`}
5665
onConfirm={onConfirm}
5766
onClose={onCancel}
5867
confirmLoading={confirmLoading}
5968
disabled={!deletionConfirmed}
69+
confirmText={confirmText}
6070
description={
6171
<>
62-
<p>Deleting this {entity} is irreversible!</p>
72+
<p>
73+
{verb ?? "Deleting"} this {entity} is irreversible!
74+
</p>
6375

6476
{Boolean(info) && (
6577
<p css={{ color: theme.palette.warning.light }}>{info}</p>
@@ -84,7 +96,7 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
8496
onChange={(event) => setUserConfirmationText(event.target.value)}
8597
onFocus={() => setIsFocused(true)}
8698
onBlur={() => setIsFocused(false)}
87-
label={`Name of the ${entity} to delete`}
99+
label={label ?? `Name of the ${entity} to delete`}
88100
color={inputColor}
89101
error={displayErrorMessage}
90102
helperText={

site/src/components/SettingsLayout/Sidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SidebarHeader,
1212
SidebarNavItem,
1313
} from "components/Sidebar/Sidebar";
14+
import { GitIcon } from "components/Icons/GitIcon";
1415

1516
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
1617
const { entitlements } = useDashboard();
@@ -40,6 +41,9 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
4041
<SidebarNavItem href="ssh-keys" icon={FingerprintOutlinedIcon}>
4142
SSH Keys
4243
</SidebarNavItem>
44+
<SidebarNavItem href="external-auth" icon={GitIcon}>
45+
External Authentication
46+
</SidebarNavItem>
4347
<SidebarNavItem href="tokens" icon={VpnKeyOutlined}>
4448
Tokens
4549
</SidebarNavItem>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { FC, useState } from "react";
2+
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
3+
import {
4+
listUserExternalAuths,
5+
unlinkExternalAuths,
6+
validateExternalAuth,
7+
} from "api/queries/externalauth";
8+
import { Section } from "components/SettingsLayout/Section";
9+
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
10+
import { useMutation, useQuery, useQueryClient } from "react-query";
11+
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
12+
import { getErrorMessage } from "api/errors";
13+
14+
const UserExternalAuthSettingsPage: FC = () => {
15+
const queryClient = useQueryClient();
16+
17+
const userExternalAuthsQuery = useQuery(listUserExternalAuths());
18+
19+
const [appToUnlink, setAppToUnlink] = useState<string>();
20+
const unlinkAppMutation = useMutation(unlinkExternalAuths(queryClient));
21+
22+
const validateAppMutation = useMutation(validateExternalAuth(queryClient));
23+
24+
return (
25+
<Section title="External Authentication">
26+
<UserExternalAuthSettingsPageView
27+
isLoading={userExternalAuthsQuery.isLoading}
28+
getAuthsError={userExternalAuthsQuery.error}
29+
auths={userExternalAuthsQuery.data}
30+
onUnlinkExternalAuth={(providerID: string) => {
31+
setAppToUnlink(providerID);
32+
}}
33+
onValidateExternalAuth={async (providerID: string) => {
34+
try {
35+
await validateAppMutation.mutateAsync(providerID, {
36+
onSuccess: (data) => {
37+
if (data.authenticated) {
38+
displaySuccess("Application link is valid.");
39+
} else {
40+
displayError(
41+
"Application link is not valid. Please unlink the application and reauthenticate.",
42+
);
43+
}
44+
},
45+
});
46+
} catch (e) {
47+
displayError(
48+
getErrorMessage(e, "Error validating application link."),
49+
);
50+
}
51+
}}
52+
/>
53+
<DeleteDialog
54+
key={appToUnlink}
55+
title="Unlink Application"
56+
verb="Unlinking"
57+
info="This does not revoke the access token from the oauth2 provider.
58+
It only removes the link on this side. To fully revoke access, you must
59+
do so on the oauth2 provider's side."
60+
label="Name of the application to unlink"
61+
isOpen={appToUnlink !== undefined}
62+
confirmLoading={unlinkAppMutation.isLoading}
63+
name={appToUnlink ?? ""}
64+
entity="application"
65+
onCancel={() => setAppToUnlink(undefined)}
66+
onConfirm={async () => {
67+
try {
68+
await unlinkAppMutation.mutateAsync(appToUnlink!);
69+
setAppToUnlink(undefined);
70+
displaySuccess("Successfully unlinked the oauth2 application.");
71+
} catch (e) {
72+
displayError(getErrorMessage(e, "Error unlinking application."));
73+
}
74+
}}
75+
/>
76+
</Section>
77+
);
78+
};
79+
80+
export default UserExternalAuthSettingsPage;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
2+
// import type { Meta, StoryObj } from "@storybook/react";
3+
4+
// const meta: Meta<typeof ExternalAuthSettingsPageView> = {
5+
// title: "pages/DeploySettingsPage/ExternalAuthSettingsPageView",
6+
// component: ExternalAuthSettingsPageView,
7+
// args: {
8+
// config: {
9+
// external_auth: [
10+
// {
11+
// id: "0000-1111",
12+
// type: "GitHub",
13+
// client_id: "client_id",
14+
// regex: "regex",
15+
// auth_url: "",
16+
// token_url: "",
17+
// validate_url: "",
18+
// app_install_url: "https://github.com/apps/coder/installations/new",
19+
// app_installations_url: "",
20+
// no_refresh: false,
21+
// scopes: [],
22+
// extra_token_keys: [],
23+
// device_flow: true,
24+
// device_code_url: "",
25+
// display_icon: "",
26+
// display_name: "GitHub",
27+
// },
28+
// ],
29+
// },
30+
// },
31+
// };
32+
33+
// export default meta;
34+
// type Story = StoryObj<typeof ExternalAuthSettingsPageView>;
35+
36+
// export const Page: Story = {};

0 commit comments

Comments
 (0)