> = ({
type="delete"
hideCancel={false}
open={isOpen}
- title={`Delete ${entity}`}
+ title={title ?? `Delete ${entity}`}
onConfirm={onConfirm}
onClose={onCancel}
confirmLoading={confirmLoading}
disabled={!deletionConfirmed}
+ confirmText={confirmText}
description={
<>
- Deleting this {entity} is irreversible!
+
+ {verb ?? "Deleting"} this {entity} is irreversible!
+
{Boolean(info) && (
{info}
@@ -84,7 +96,7 @@ export const DeleteDialog: FC> = ({
onChange={(event) => setUserConfirmationText(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
- label={`Name of the ${entity} to delete`}
+ label={label ?? `Name of the ${entity} to delete`}
color={inputColor}
error={displayErrorMessage}
helperText={
diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx
index 6657aab80ab58..a6fc879171c49 100644
--- a/site/src/components/SettingsLayout/Sidebar.tsx
+++ b/site/src/components/SettingsLayout/Sidebar.tsx
@@ -11,6 +11,7 @@ import {
SidebarHeader,
SidebarNavItem,
} from "components/Sidebar/Sidebar";
+import { GitIcon } from "components/Icons/GitIcon";
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
const { entitlements } = useDashboard();
@@ -40,6 +41,9 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
SSH Keys
+
+ External Authentication
+
Tokens
diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx
index c1e61ac74832b..691b6681afddb 100644
--- a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx
+++ b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx
@@ -15,6 +15,7 @@ export interface ExternalAuthProps {
externalAuthPollingState: ExternalAuthPollingState;
startPollingExternalAuth: () => void;
error?: string;
+ message?: string;
}
export const ExternalAuth: FC = (props) => {
@@ -26,8 +27,14 @@ export const ExternalAuth: FC = (props) => {
externalAuthPollingState,
startPollingExternalAuth,
error,
+ message,
} = props;
+ const messageContent =
+ message ??
+ (authenticated
+ ? `Authenticated with ${displayName}`
+ : `Login with ${displayName}`);
return (
= (props) => {
variant="contained"
size="large"
startIcon={
-
+ displayIcon && (
+
+ )
}
disabled={authenticated}
css={{ height: 52 }}
@@ -61,9 +70,7 @@ export const ExternalAuth: FC = (props) => {
startPollingExternalAuth();
}}
>
- {authenticated
- ? `Authenticated with ${displayName}`
- : `Login with ${displayName}`}
+ {messageContent}
{externalAuthPollingState === "abandoned" && (
diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx
new file mode 100644
index 0000000000000..ced75156bd0c5
--- /dev/null
+++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx
@@ -0,0 +1,99 @@
+import { FC, useState } from "react";
+import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
+import {
+ listUserExternalAuths,
+ unlinkExternalAuths,
+ validateExternalAuth,
+} from "api/queries/externalauth";
+import { Section } from "components/SettingsLayout/Section";
+import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
+import { getErrorMessage } from "api/errors";
+
+const UserExternalAuthSettingsPage: FC = () => {
+ const queryClient = useQueryClient();
+ // This is used to tell the child components something was unlinked and things
+ // need to be refetched
+ const [unlinked, setUnlinked] = useState(0);
+
+ const {
+ data: externalAuths,
+ error,
+ isLoading,
+ refetch,
+ } = useQuery(listUserExternalAuths());
+
+ const [appToUnlink, setAppToUnlink] = useState();
+ const mutateParams = unlinkExternalAuths(queryClient);
+ const unlinkAppMutation = useMutation({
+ ...mutateParams,
+ onSuccess: async () => {
+ await mutateParams.onSuccess();
+ },
+ });
+
+ const validateAppMutation = useMutation(validateExternalAuth(queryClient));
+
+ return (
+
+ {
+ setAppToUnlink(providerID);
+ }}
+ onValidateExternalAuth={async (providerID: string) => {
+ try {
+ const data = await validateAppMutation.mutateAsync(providerID);
+ if (data.authenticated) {
+ displaySuccess("Application link is valid.");
+ } else {
+ displayError(
+ "Application link is not valid. Please unlink the application and reauthenticate.",
+ );
+ }
+ } catch (e) {
+ displayError(
+ getErrorMessage(e, "Error validating application link."),
+ );
+ }
+ }}
+ />
+ setAppToUnlink(undefined)}
+ onConfirm={async () => {
+ try {
+ await unlinkAppMutation.mutateAsync(appToUnlink!);
+ // setAppToUnlink closes the modal
+ setAppToUnlink(undefined);
+ // refetch repopulates the external auth data
+ await refetch();
+ // this tells our child components to refetch their data
+ // as at least 1 provider was unlinked.
+ setUnlinked(unlinked + 1);
+
+ displaySuccess("Successfully unlinked the oauth2 application.");
+ } catch (e) {
+ displayError(getErrorMessage(e, "Error unlinking application."));
+ }
+ }}
+ />
+
+ );
+};
+
+export default UserExternalAuthSettingsPage;
diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx
new file mode 100644
index 0000000000000..3419f0f49a69c
--- /dev/null
+++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx
@@ -0,0 +1,52 @@
+import {
+ MockGithubAuthLink,
+ MockGithubExternalProvider,
+} from "testHelpers/entities";
+import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta: Meta = {
+ title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView",
+ component: UserExternalAuthSettingsPageView,
+ args: {
+ isLoading: false,
+ getAuthsError: undefined,
+ unlinked: 0,
+ auths: {
+ providers: [],
+ links: [],
+ },
+ onUnlinkExternalAuth: () => {},
+ onValidateExternalAuth: () => {},
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const NoProviders: Story = {};
+
+export const Authenticated: Story = {
+ args: {
+ ...meta.args,
+ auths: {
+ providers: [MockGithubExternalProvider],
+ links: [MockGithubAuthLink],
+ },
+ },
+};
+
+export const UnAuthenticated: Story = {
+ args: {
+ ...meta.args,
+ auths: {
+ providers: [MockGithubExternalProvider],
+ links: [
+ {
+ ...MockGithubAuthLink,
+ authenticated: false,
+ },
+ ],
+ },
+ },
+};
diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx
new file mode 100644
index 0000000000000..96ef1066f43b7
--- /dev/null
+++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx
@@ -0,0 +1,240 @@
+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 {
+ ListUserExternalAuthResponse,
+ ExternalAuthLinkProvider,
+ ExternalAuthLink,
+} from "api/typesGenerated";
+import { ErrorAlert } from "components/Alert/ErrorAlert";
+import { Avatar } from "components/Avatar/Avatar";
+import { AvatarData } from "components/AvatarData/AvatarData";
+import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth";
+import Divider from "@mui/material/Divider";
+import {
+ MoreMenu,
+ MoreMenuContent,
+ MoreMenuItem,
+ MoreMenuTrigger,
+ ThreeDotsButton,
+} from "components/MoreMenu/MoreMenu";
+import { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage";
+import { useState, useCallback, useEffect } from "react";
+import { useQuery } from "react-query";
+import { userExternalAuth } from "api/queries/externalauth";
+import { FullScreenLoader } from "components/Loader/FullScreenLoader";
+
+export type UserExternalAuthSettingsPageViewProps = {
+ isLoading: boolean;
+ getAuthsError?: unknown;
+ unlinked: number;
+ auths?: ListUserExternalAuthResponse;
+ onUnlinkExternalAuth: (provider: string) => void;
+ onValidateExternalAuth: (provider: string) => void;
+};
+
+export const UserExternalAuthSettingsPageView = ({
+ isLoading,
+ getAuthsError,
+ auths,
+ unlinked,
+ onUnlinkExternalAuth,
+ onValidateExternalAuth,
+}: UserExternalAuthSettingsPageViewProps): JSX.Element => {
+ if (getAuthsError) {
+ // Nothing to show if there is an error
+ return ;
+ }
+
+ if (isLoading || !auths) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+ Application
+ Link
+
+
+
+
+ {((auths.providers === null || auths.providers?.length === 0) && (
+
+
+
+ No providers have been configured!
+
+
+
+ )) ||
+ auths.providers?.map((app: ExternalAuthLinkProvider) => {
+ return (
+ l.provider_id === app.id)}
+ onUnlinkExternalAuth={() => {
+ onUnlinkExternalAuth(app.id);
+ }}
+ onValidateExternalAuth={() => {
+ onValidateExternalAuth(app.id);
+ }}
+ />
+ );
+ })}
+
+
+
+ >
+ );
+};
+
+interface ExternalAuthRowProps {
+ app: ExternalAuthLinkProvider;
+ link?: ExternalAuthLink;
+ unlinked: number;
+ onUnlinkExternalAuth: () => void;
+ onValidateExternalAuth: () => void;
+}
+
+const ExternalAuthRow = ({
+ app,
+ unlinked,
+ link,
+ onUnlinkExternalAuth,
+ onValidateExternalAuth,
+}: ExternalAuthRowProps): JSX.Element => {
+ const name = app.id || app.type;
+ const authURL = "/external-auth/" + app.id;
+
+ const {
+ externalAuth,
+ externalAuthPollingState,
+ refetch,
+ startPollingExternalAuth,
+ } = useExternalAuth(app.id, unlinked);
+
+ const authenticated = externalAuth
+ ? externalAuth.authenticated
+ : link?.authenticated ?? false;
+
+ return (
+
+
+
+ )
+ }
+ />
+
+
+
+
+
+ {(link || externalAuth?.authenticated) && (
+
+
+
+
+
+ {
+ onValidateExternalAuth();
+ // This is kinda jank. It does a refetch of the thing
+ // it just validated... But we need to refetch to update the
+ // login button. And the 'onValidateExternalAuth' does the
+ // message display.
+ await refetch();
+ }}
+ >
+ Test Validate…
+
+
+ {
+ onUnlinkExternalAuth();
+ await refetch();
+ }}
+ >
+ Unlink…
+
+
+
+ )}
+
+
+ );
+};
+
+// useExternalAuth handles the polling of the auth to update the button.
+const useExternalAuth = (providerID: string, unlinked: number) => {
+ const [externalAuthPollingState, setExternalAuthPollingState] =
+ useState("idle");
+
+ const startPollingExternalAuth = useCallback(() => {
+ setExternalAuthPollingState("polling");
+ }, []);
+
+ const { data: externalAuth, refetch } = useQuery({
+ ...userExternalAuth(providerID),
+ refetchInterval: externalAuthPollingState === "polling" ? 1000 : false,
+ });
+
+ const signedIn = externalAuth?.authenticated;
+
+ useEffect(() => {
+ if (unlinked > 0) {
+ void refetch();
+ }
+ }, [refetch, unlinked]);
+
+ useEffect(() => {
+ if (signedIn) {
+ setExternalAuthPollingState("idle");
+ return;
+ }
+
+ if (externalAuthPollingState !== "polling") {
+ return;
+ }
+
+ // Poll for a maximum of one minute
+ const quitPolling = setTimeout(
+ () => setExternalAuthPollingState("abandoned"),
+ 60_000,
+ );
+ return () => {
+ clearTimeout(quitPolling);
+ };
+ }, [externalAuthPollingState, signedIn]);
+
+ return {
+ startPollingExternalAuth,
+ externalAuth,
+ externalAuthPollingState,
+ refetch,
+ };
+};
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index 2b422cefe5d68..555b21058e382 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -3148,3 +3148,23 @@ export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = {
},
},
};
+
+export const MockGithubExternalProvider: TypesGen.ExternalAuthLinkProvider = {
+ id: "github",
+ type: "github",
+ device: false,
+ display_icon: "/icon/github.svg",
+ display_name: "GitHub",
+ allow_refresh: true,
+ allow_validate: true,
+};
+
+export const MockGithubAuthLink: TypesGen.ExternalAuthLink = {
+ provider_id: "github",
+ created_at: "",
+ updated_at: "",
+ has_refresh_token: true,
+ expires: "",
+ authenticated: true,
+ validate_error: "",
+};