mirror of https://github.com/coder/coder.git
feat: add user/settings page for managing external auth (#10945)
Also add support for unlinking on the coder side to allow reflow.
This commit is contained in:
parent
f6891bc465
commit
b376b2cd13
|
@ -8907,6 +8907,9 @@ const docTemplate = `{
|
|||
"codersdk.ExternalAuthLink": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"authenticated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
|
@ -8924,6 +8927,9 @@ const docTemplate = `{
|
|||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"validate_error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -7993,6 +7993,9 @@
|
|||
"codersdk.ExternalAuthLink": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"authenticated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
|
@ -8010,6 +8013,9 @@
|
|||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"validate_error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -16,21 +16,28 @@ import (
|
|||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func ExternalAuths(auths []database.ExternalAuthLink) []codersdk.ExternalAuthLink {
|
||||
type ExternalAuthMeta struct {
|
||||
Authenticated bool
|
||||
ValidateError string
|
||||
}
|
||||
|
||||
func ExternalAuths(auths []database.ExternalAuthLink, meta map[string]ExternalAuthMeta) []codersdk.ExternalAuthLink {
|
||||
out := make([]codersdk.ExternalAuthLink, 0, len(auths))
|
||||
for _, auth := range auths {
|
||||
out = append(out, ExternalAuth(auth))
|
||||
out = append(out, ExternalAuth(auth, meta[auth.ProviderID]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ExternalAuth(auth database.ExternalAuthLink) codersdk.ExternalAuthLink {
|
||||
func ExternalAuth(auth database.ExternalAuthLink, meta ExternalAuthMeta) codersdk.ExternalAuthLink {
|
||||
return codersdk.ExternalAuthLink{
|
||||
ProviderID: auth.ProviderID,
|
||||
CreatedAt: auth.CreatedAt,
|
||||
UpdatedAt: auth.UpdatedAt,
|
||||
HasRefreshToken: auth.OAuthRefreshToken != "",
|
||||
Expires: auth.OAuthExpiry,
|
||||
Authenticated: meta.Authenticated,
|
||||
ValidateError: meta.ValidateError,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -337,6 +337,36 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// This process of authenticating each external link increases the
|
||||
// response time. However, it is necessary to more correctly debug
|
||||
// authentication issues.
|
||||
// We can do this in parallel if we want to speed it up.
|
||||
configs := make(map[string]*externalauth.Config)
|
||||
for _, cfg := range api.ExternalAuthConfigs {
|
||||
configs[cfg.ID] = cfg
|
||||
}
|
||||
// Check if the links are authenticated.
|
||||
linkMeta := make(map[string]db2sdk.ExternalAuthMeta)
|
||||
for i, link := range links {
|
||||
if link.OAuthAccessToken != "" {
|
||||
cfg, ok := configs[link.ProviderID]
|
||||
if ok {
|
||||
newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link)
|
||||
meta := db2sdk.ExternalAuthMeta{
|
||||
Authenticated: valid,
|
||||
}
|
||||
if err != nil {
|
||||
meta.ValidateError = err.Error()
|
||||
}
|
||||
// Update the link if it was potentially refreshed.
|
||||
if err == nil && valid {
|
||||
links[i] = newLink
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: It would be really nice if we could cfg.Validate() the links and
|
||||
// return their authenticated status. To do this, we would also have to
|
||||
// refresh expired tokens too. For now, I do not want to cause the excess
|
||||
|
@ -344,7 +374,7 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) {
|
|||
// call.
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{
|
||||
Providers: ExternalAuthConfigs(api.ExternalAuthConfigs),
|
||||
Links: db2sdk.ExternalAuths(links),
|
||||
Links: db2sdk.ExternalAuths(links, linkMeta),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -76,6 +76,8 @@ type ExternalAuthLink struct {
|
|||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
HasRefreshToken bool `json:"has_refresh_token"`
|
||||
Expires time.Time `json:"expires" format:"date-time"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
ValidateError string `json:"validate_error"`
|
||||
}
|
||||
|
||||
// ExternalAuthLinkProvider are the static details of a provider.
|
||||
|
|
|
@ -19,11 +19,13 @@ curl -X GET http://coder-server:8080/api/v2/external-auth \
|
|||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"expires": "2019-08-24T14:15:22Z",
|
||||
"has_refresh_token": true,
|
||||
"provider_id": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"validate_error": "string"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
@ -3005,11 +3005,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"expires": "2019-08-24T14:15:22Z",
|
||||
"has_refresh_token": true,
|
||||
"provider_id": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"validate_error": "string"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -3017,11 +3019,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------- | ------- | -------- | ------------ | ----------- |
|
||||
| `authenticated` | boolean | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `expires` | string | false | | |
|
||||
| `has_refresh_token` | boolean | false | | |
|
||||
| `provider_id` | string | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
| `validate_error` | string | false | | |
|
||||
|
||||
## codersdk.ExternalAuthUser
|
||||
|
||||
|
|
|
@ -132,6 +132,10 @@ const ObservabilitySettingsPage = lazy(
|
|||
const ExternalAuthPage = lazy(
|
||||
() => import("./pages/ExternalAuthPage/ExternalAuthPage"),
|
||||
);
|
||||
const UserExternalAuthSettingsPage = lazy(
|
||||
() =>
|
||||
import("./pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage"),
|
||||
);
|
||||
const TemplateVersionPage = lazy(
|
||||
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
|
||||
);
|
||||
|
@ -265,6 +269,10 @@ export const AppRouter: FC = () => {
|
|||
<Route path="versions">
|
||||
<Route path=":version">
|
||||
<Route index element={<TemplateVersionPage />} />
|
||||
<Route
|
||||
path="edit"
|
||||
element={<TemplateVersionEditorPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
@ -320,6 +328,10 @@ export const AppRouter: FC = () => {
|
|||
<Route path="schedule" element={<SchedulePage />} />
|
||||
<Route path="security" element={<SecurityPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route
|
||||
path="external-auth"
|
||||
element={<UserExternalAuthSettingsPage />}
|
||||
/>
|
||||
<Route path="tokens">
|
||||
<Route index element={<TokensPage />} />
|
||||
<Route path="new" element={<CreateTokenPage />} />
|
||||
|
@ -366,17 +378,13 @@ export const AppRouter: FC = () => {
|
|||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Pages that don't have the dashboard layout */}
|
||||
{/* Terminal and CLI auth pages don't have the dashboard layout */}
|
||||
<Route
|
||||
path="/:username/:workspace/terminal"
|
||||
element={<TerminalPage />}
|
||||
/>
|
||||
<Route path="/cli-auth" element={<CliAuthenticationPage />} />
|
||||
<Route path="/icons" element={<IconsPage />} />
|
||||
<Route
|
||||
path="/templates/:template/versions/:version/edit"
|
||||
element={<TemplateVersionEditorPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
|
|
|
@ -939,6 +939,19 @@ export const exchangeExternalAuthDevice = async (
|
|||
return resp.data;
|
||||
};
|
||||
|
||||
export const getUserExternalAuthProviders =
|
||||
async (): Promise<TypesGen.ListUserExternalAuthResponse> => {
|
||||
const resp = await axios.get(`/api/v2/external-auth`);
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
export const unlinkExternalAuthProvider = async (
|
||||
provider: string,
|
||||
): Promise<string> => {
|
||||
const resp = await axios.delete(`/api/v2/external-auth/${provider}`);
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
export const getAuditLogs = async (
|
||||
options: TypesGen.AuditLogsRequest,
|
||||
): Promise<TypesGen.AuditLogResponse> => {
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import * as API from "api/api";
|
||||
import { QueryClient } from "react-query";
|
||||
|
||||
const getUserExternalAuthsKey = () => ["list", "external-auth"];
|
||||
|
||||
// listUserExternalAuths returns all configured external auths for a given user.
|
||||
export const listUserExternalAuths = () => {
|
||||
return {
|
||||
queryKey: getUserExternalAuthsKey(),
|
||||
queryFn: () => API.getUserExternalAuthProviders(),
|
||||
};
|
||||
};
|
||||
|
||||
const getUserExternalAuthKey = (providerID: string) => [
|
||||
providerID,
|
||||
"get",
|
||||
"external-auth",
|
||||
];
|
||||
|
||||
export const userExternalAuth = (providerID: string) => {
|
||||
return {
|
||||
queryKey: getUserExternalAuthKey(providerID),
|
||||
queryFn: () => API.getExternalAuthProvider(providerID),
|
||||
};
|
||||
};
|
||||
|
||||
export const validateExternalAuth = (_: QueryClient) => {
|
||||
return {
|
||||
mutationFn: API.getExternalAuthProvider,
|
||||
};
|
||||
};
|
||||
|
||||
export const unlinkExternalAuths = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: API.unlinkExternalAuthProvider,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(["external-auth"]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -502,6 +502,8 @@ export interface ExternalAuthLink {
|
|||
readonly updated_at: string;
|
||||
readonly has_refresh_token: boolean;
|
||||
readonly expires: string;
|
||||
readonly authenticated: boolean;
|
||||
readonly validate_error: string;
|
||||
}
|
||||
|
||||
// From codersdk/externalauth.go
|
||||
|
|
|
@ -18,6 +18,10 @@ export interface DeleteDialogProps {
|
|||
name: string;
|
||||
info?: string;
|
||||
confirmLoading?: boolean;
|
||||
verb?: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
|
||||
|
@ -28,6 +32,11 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
|
|||
info,
|
||||
name,
|
||||
confirmLoading,
|
||||
// All optional to change the verbiage. For example, "unlinking" vs "deleting"
|
||||
verb,
|
||||
title,
|
||||
label,
|
||||
confirmText,
|
||||
}) => {
|
||||
const hookId = useId();
|
||||
const theme = useTheme();
|
||||
|
@ -52,14 +61,17 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
|
|||
type="delete"
|
||||
hideCancel={false}
|
||||
open={isOpen}
|
||||
title={`Delete ${entity}`}
|
||||
title={title ?? `Delete ${entity}`}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onCancel}
|
||||
confirmLoading={confirmLoading}
|
||||
disabled={!deletionConfirmed}
|
||||
confirmText={confirmText}
|
||||
description={
|
||||
<>
|
||||
<p>Deleting this {entity} is irreversible!</p>
|
||||
<p>
|
||||
{verb ?? "Deleting"} this {entity} is irreversible!
|
||||
</p>
|
||||
|
||||
{Boolean(info) && (
|
||||
<p css={{ color: theme.palette.warning.light }}>{info}</p>
|
||||
|
@ -84,7 +96,7 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
|
|||
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={
|
||||
|
|
|
@ -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 }) => {
|
|||
<SidebarNavItem href="ssh-keys" icon={FingerprintOutlinedIcon}>
|
||||
SSH Keys
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem href="external-auth" icon={GitIcon}>
|
||||
External Authentication
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem href="tokens" icon={VpnKeyOutlined}>
|
||||
Tokens
|
||||
</SidebarNavItem>
|
||||
|
|
|
@ -15,6 +15,7 @@ export interface ExternalAuthProps {
|
|||
externalAuthPollingState: ExternalAuthPollingState;
|
||||
startPollingExternalAuth: () => void;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
|
||||
|
@ -26,8 +27,14 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
|
|||
externalAuthPollingState,
|
||||
startPollingExternalAuth,
|
||||
error,
|
||||
message,
|
||||
} = props;
|
||||
|
||||
const messageContent =
|
||||
message ??
|
||||
(authenticated
|
||||
? `Authenticated with ${displayName}`
|
||||
: `Login with ${displayName}`);
|
||||
return (
|
||||
<Tooltip
|
||||
title={authenticated && `${displayName} has already been connected.`}
|
||||
|
@ -40,12 +47,14 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
|
|||
variant="contained"
|
||||
size="large"
|
||||
startIcon={
|
||||
<img
|
||||
src={displayIcon}
|
||||
alt={`${displayName} Icon`}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
displayIcon && (
|
||||
<img
|
||||
src={displayIcon}
|
||||
alt={`${displayName} Icon`}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
disabled={authenticated}
|
||||
css={{ height: 52 }}
|
||||
|
@ -61,9 +70,7 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
|
|||
startPollingExternalAuth();
|
||||
}}
|
||||
>
|
||||
{authenticated
|
||||
? `Authenticated with ${displayName}`
|
||||
: `Login with ${displayName}`}
|
||||
{messageContent}
|
||||
</LoadingButton>
|
||||
|
||||
{externalAuthPollingState === "abandoned" && (
|
||||
|
|
|
@ -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<string>();
|
||||
const mutateParams = unlinkExternalAuths(queryClient);
|
||||
const unlinkAppMutation = useMutation({
|
||||
...mutateParams,
|
||||
onSuccess: async () => {
|
||||
await mutateParams.onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
const validateAppMutation = useMutation(validateExternalAuth(queryClient));
|
||||
|
||||
return (
|
||||
<Section title="External Authentication">
|
||||
<UserExternalAuthSettingsPageView
|
||||
isLoading={isLoading}
|
||||
getAuthsError={error}
|
||||
auths={externalAuths}
|
||||
unlinked={unlinked}
|
||||
onUnlinkExternalAuth={(providerID: string) => {
|
||||
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."),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DeleteDialog
|
||||
key={appToUnlink}
|
||||
title="Unlink Application"
|
||||
verb="Unlinking"
|
||||
info="This does not revoke the access token from the oauth2 provider.
|
||||
It only removes the link on this side. To fully revoke access, you must
|
||||
do so on the oauth2 provider's side."
|
||||
label="Name of the application to unlink"
|
||||
isOpen={appToUnlink !== undefined}
|
||||
confirmLoading={unlinkAppMutation.isLoading}
|
||||
name={appToUnlink ?? ""}
|
||||
entity="application"
|
||||
onCancel={() => 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."));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserExternalAuthSettingsPage;
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
MockGithubAuthLink,
|
||||
MockGithubExternalProvider,
|
||||
} from "testHelpers/entities";
|
||||
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof UserExternalAuthSettingsPageView> = {
|
||||
title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView",
|
||||
component: UserExternalAuthSettingsPageView,
|
||||
args: {
|
||||
isLoading: false,
|
||||
getAuthsError: undefined,
|
||||
unlinked: 0,
|
||||
auths: {
|
||||
providers: [],
|
||||
links: [],
|
||||
},
|
||||
onUnlinkExternalAuth: () => {},
|
||||
onValidateExternalAuth: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UserExternalAuthSettingsPageView>;
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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 <ErrorAlert error={getAuthsError} />;
|
||||
}
|
||||
|
||||
if (isLoading || !auths) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Application</TableCell>
|
||||
<TableCell>Link</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{((auths.providers === null || auths.providers?.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<div css={{ textAlign: "center" }}>
|
||||
No providers have been configured!
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) ||
|
||||
auths.providers?.map((app: ExternalAuthLinkProvider) => {
|
||||
return (
|
||||
<ExternalAuthRow
|
||||
key={app.id}
|
||||
app={app}
|
||||
unlinked={unlinked}
|
||||
link={auths.links.find((l) => l.provider_id === app.id)}
|
||||
onUnlinkExternalAuth={() => {
|
||||
onUnlinkExternalAuth(app.id);
|
||||
}}
|
||||
onValidateExternalAuth={() => {
|
||||
onValidateExternalAuth(app.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<TableRow key={name}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={app.display_name || app.id}
|
||||
// subtitle={template.description}
|
||||
avatar={
|
||||
app.display_icon !== "" && (
|
||||
<Avatar src={app.display_icon} variant="square" fitImage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ExternalAuth
|
||||
displayName={name}
|
||||
// We could specify the user is linked, but the link is invalid.
|
||||
// This could indicate it expired, or was revoked on the other end.
|
||||
authenticated={authenticated}
|
||||
authenticateURL={authURL}
|
||||
displayIcon=""
|
||||
message={authenticated ? "Authenticated" : "Click to Login"}
|
||||
externalAuthPollingState={externalAuthPollingState}
|
||||
startPollingExternalAuth={startPollingExternalAuth}
|
||||
></ExternalAuth>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(link || externalAuth?.authenticated) && (
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton />
|
||||
</MoreMenuTrigger>
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
onClick={async () => {
|
||||
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…
|
||||
</MoreMenuItem>
|
||||
<Divider />
|
||||
<MoreMenuItem
|
||||
danger
|
||||
onClick={async () => {
|
||||
onUnlinkExternalAuth();
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
Unlink…
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// useExternalAuth handles the polling of the auth to update the button.
|
||||
const useExternalAuth = (providerID: string, unlinked: number) => {
|
||||
const [externalAuthPollingState, setExternalAuthPollingState] =
|
||||
useState<ExternalAuthPollingState>("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,
|
||||
};
|
||||
};
|
|
@ -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: "",
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue