feat: add ability for users to convert their password login type to oauth/github login (#8105)

* Currently toggled by experiment flag

---------

Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
Steven Masley
2023-06-30 08:38:48 -04:00
committed by GitHub
co-authored by Bruno Quaresma
parent 357f3b38f7
commit b5f26d9bdf
50 changed files with 2043 additions and 261 deletions
+15
View File
@@ -108,6 +108,14 @@ export const login = async (
return response.data
}
export const convertToOAUTH = async (request: TypesGen.ConvertLoginRequest) => {
const response = await axios.post<TypesGen.OAuthConversionResponse>(
"/api/v2/users/me/convert-login",
request,
)
return response.data
}
export const logout = async (): Promise<void> => {
await axios.post("/api/v2/users/logout")
}
@@ -134,6 +142,13 @@ export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
return response.data
}
export const getUserLoginType = async (): Promise<TypesGen.UserLoginType> => {
const response = await axios.get<TypesGen.UserLoginType>(
"/api/v2/users/me/login-type",
)
return response.data
}
export const checkAuthorization = async (
params: TypesGen.AuthorizationRequest,
): Promise<TypesGen.AuthorizationResponse> => {
+24
View File
@@ -104,6 +104,7 @@ export interface AuthMethod {
// From codersdk/users.go
export interface AuthMethods {
readonly convert_to_oidc_enabled: boolean
readonly password: AuthMethod
readonly github: AuthMethod
readonly oidc: OIDCAuthMethod
@@ -139,6 +140,12 @@ export interface BuildInfoResponse {
readonly workspace_proxy: boolean
}
// From codersdk/users.go
export interface ConvertLoginRequest {
readonly to_type: LoginType
readonly password: string
}
// From codersdk/users.go
export interface CreateFirstUserRequest {
readonly email: string
@@ -561,6 +568,14 @@ export interface OAuth2GithubConfig {
readonly enterprise_base_url: string
}
// From codersdk/users.go
export interface OAuthConversionResponse {
readonly state_string: string
readonly expires_at: string
readonly to_type: LoginType
readonly user_id: string
}
// From codersdk/users.go
export interface OIDCAuthMethod extends AuthMethod {
readonly signInText: string
@@ -1082,6 +1097,11 @@ export interface User {
readonly avatar_url: string
}
// From codersdk/users.go
export interface UserLoginType {
readonly login_type: LoginType
}
// From codersdk/users.go
export interface UserRoles {
readonly roles: string[]
@@ -1399,10 +1419,12 @@ export const Entitlements: Entitlement[] = [
// From codersdk/deployment.go
export type Experiment =
| "convert-to-oidc"
| "moons"
| "tailnet_pg_coordinator"
| "workspace_actions"
export const Experiments: Experiment[] = [
"convert-to-oidc",
"moons",
"tailnet_pg_coordinator",
"workspace_actions",
@@ -1565,6 +1587,7 @@ export const RBACResources: RBACResource[] = [
// From codersdk/audit.go
export type ResourceType =
| "api_key"
| "convert_login"
| "git_ssh_key"
| "group"
| "license"
@@ -1575,6 +1598,7 @@ export type ResourceType =
| "workspace_build"
export const ResourceTypes: ResourceType[] = [
"api_key",
"convert_login",
"git_ssh_key",
"group",
"license",
@@ -31,7 +31,7 @@ describe("AccountForm", () => {
const el = await screen.findByLabelText("Username")
expect(el).toBeEnabled()
const btn = await screen.findByRole("button", {
name: /Update settings/i,
name: /Update account/i,
})
expect(btn).toBeEnabled()
})
@@ -61,7 +61,7 @@ describe("AccountForm", () => {
const el = await screen.findByLabelText("Username")
expect(el).toBeDisabled()
const btn = await screen.findByRole("button", {
name: /Update settings/i,
name: /Update account/i,
})
expect(btn).toBeDisabled()
})
@@ -8,8 +8,8 @@ import {
onChangeTrimmed,
} from "../../utils/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { Form, FormFields } from "components/Form/Form"
export interface AccountFormValues {
username: string
@@ -18,7 +18,7 @@ export interface AccountFormValues {
export const Language = {
usernameLabel: "Username",
emailLabel: "Email",
updateSettings: "Update settings",
updateSettings: "Update account",
}
const validationSchema = Yup.object({
@@ -59,8 +59,8 @@ export const AccountForm: FC<React.PropsWithChildren<AccountFormProps>> = ({
return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(updateProfileError) && (
<ErrorAlert error={updateProfileError} />
)}
@@ -91,8 +91,8 @@ export const AccountForm: FC<React.PropsWithChildren<AccountFormProps>> = ({
{isLoading ? "" : Language.updateSettings}
</LoadingButton>
</div>
</Stack>
</form>
</FormFields>
</Form>
</>
)
}
@@ -6,6 +6,8 @@ import { SectionAction } from "../SectionAction/SectionAction"
type SectionLayout = "fixed" | "fluid"
export interface SectionProps {
// Useful for testing
id?: string
title?: ReactNode | string
description?: ReactNode
toolbar?: ReactNode
@@ -20,6 +22,7 @@ type SectionFC = FC<PropsWithChildren<SectionProps>> & {
}
export const Section: SectionFC = ({
id,
title,
description,
toolbar,
@@ -30,12 +33,16 @@ export const Section: SectionFC = ({
}) => {
const styles = useStyles({ layout })
return (
<section className={className}>
<section className={className} id={id} data-testid={id}>
<div className={styles.inner}>
{(title || description) && (
<div className={styles.header}>
<div>
{title && <Typography variant="h4">{title}</Typography>}
{title && (
<Typography variant="h4" sx={{ fontSize: 24 }}>
{title}
</Typography>
)}
{description && typeof description === "string" && (
<Typography className={styles.description}>
{description}
@@ -17,12 +17,6 @@ const Template: Story<SecurityFormProps> = (args: SecurityFormProps) => (
export const Example = Template.bind({})
Example.args = {
isLoading: false,
initialValues: {
old_password: "",
password: "",
confirm_password: "",
},
updateSecurityError: undefined,
onSubmit: () => {
return Promise.resolve()
},
@@ -37,7 +31,7 @@ Loading.args = {
export const WithError = Template.bind({})
WithError.args = {
...Example.args,
updateSecurityError: mockApiError({
error: mockApiError({
message: "Old password is incorrect",
validations: [
{
@@ -46,7 +40,4 @@ WithError.args = {
},
],
}),
initialTouched: {
old_password: true,
},
}
@@ -1,11 +1,12 @@
import TextField from "@mui/material/TextField"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { FormikContextType, useFormik } from "formik"
import { FC } from "react"
import * as Yup from "yup"
import { getFormHelpers } from "../../utils/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { Form, FormFields } from "components/Form/Form"
import { Alert } from "components/Alert/Alert"
interface SecurityFormValues {
old_password: string
@@ -41,40 +42,43 @@ const validationSchema = Yup.object({
})
export interface SecurityFormProps {
disabled: boolean
isLoading: boolean
initialValues: SecurityFormValues
onSubmit: (values: SecurityFormValues) => void
updateSecurityError?: Error | unknown
// initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<SecurityFormValues>
error?: unknown
}
export const SecurityForm: FC<SecurityFormProps> = ({
disabled,
isLoading,
onSubmit,
initialValues,
updateSecurityError,
initialTouched,
error,
}) => {
const form: FormikContextType<SecurityFormValues> =
useFormik<SecurityFormValues>({
initialValues,
initialValues: {
old_password: "",
password: "",
confirm_password: "",
},
validationSchema,
onSubmit,
initialTouched,
})
const getFieldHelpers = getFormHelpers<SecurityFormValues>(
form,
updateSecurityError,
)
const getFieldHelpers = getFormHelpers<SecurityFormValues>(form, error)
if (disabled) {
return (
<Alert severity="info">
Password changes are only allowed for password based accounts.
</Alert>
)
}
return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
{Boolean(updateSecurityError) && (
<ErrorAlert error={updateSecurityError} />
)}
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(error) && <ErrorAlert error={error} />}
<TextField
{...getFieldHelpers("old_password")}
autoComplete="old_password"
@@ -106,8 +110,8 @@ export const SecurityForm: FC<SecurityFormProps> = ({
{isLoading ? "" : Language.updatePassword}
</LoadingButton>
</div>
</Stack>
</form>
</FormFields>
</Form>
</>
)
}
@@ -28,6 +28,7 @@ SigningIn.args = {
...SignedOut.args,
isSigningIn: true,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: false, signInText: "", iconUrl: "" },
@@ -55,6 +56,7 @@ export const WithGithub = Template.bind({})
WithGithub.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: false, signInText: "", iconUrl: "" },
@@ -65,6 +67,7 @@ export const WithOIDC = Template.bind({})
WithOIDC.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: false },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@@ -75,6 +78,7 @@ export const WithOIDCWithoutPassword = Template.bind({})
WithOIDCWithoutPassword.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: false },
github: { enabled: false },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@@ -85,6 +89,7 @@ export const WithoutAny = Template.bind({})
WithoutAny.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: false },
github: { enabled: false },
oidc: { enabled: false, signInText: "", iconUrl: "" },
@@ -95,6 +100,7 @@ export const WithGithubAndOIDC = Template.bind({})
WithGithubAndOIDC.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@@ -61,6 +61,7 @@ describe("LoginPage", () => {
it("shows github authentication when enabled", async () => {
const authMethods: TypesGen.AuthMethods = {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@@ -112,6 +113,7 @@ describe("LoginPage", () => {
it("hides password authentication if OIDC/GitHub is enabled and displays on click", async () => {
const authMethods: TypesGen.AuthMethods = {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@@ -1,116 +1,159 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import * as API from "../../../api/api"
import * as SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "../../../testHelpers/renderHelpers"
import { SecurityPage } from "./SecurityPage"
import i18next from "i18next"
import { mockApiError } from "testHelpers/entities"
import {
MockAuthMethodsWithPasswordType,
mockApiError,
} from "testHelpers/entities"
import userEvent from "@testing-library/user-event"
import * as SSO from "./SingleSignOnSection"
import { OAuthConversionResponse } from "api/typesGenerated"
const { t } = i18next
const renderPage = () => {
return renderWithAuth(<SecurityPage />)
const renderPage = async () => {
const utils = renderWithAuth(<SecurityPage />)
await waitForLoaderToBeRemoved()
return utils
}
const newData = {
const newSecurityFormValues = {
old_password: "password1",
password: "password2",
confirm_password: "password2",
}
const fillAndSubmitForm = async () => {
await waitFor(() => screen.findByLabelText("Old Password"))
const fillAndSubmitSecurityForm = () => {
fireEvent.change(screen.getByLabelText("Old Password"), {
target: { value: newData.old_password },
target: { value: newSecurityFormValues.old_password },
})
fireEvent.change(screen.getByLabelText("New Password"), {
target: { value: newData.password },
target: { value: newSecurityFormValues.password },
})
fireEvent.change(screen.getByLabelText("Confirm Password"), {
target: { value: newData.confirm_password },
target: { value: newSecurityFormValues.confirm_password },
})
fireEvent.click(screen.getByText(SecurityForm.Language.updatePassword))
}
describe("SecurityPage", () => {
describe("when it is a success", () => {
it("shows the success message", async () => {
jest
.spyOn(API, "updateUserPassword")
.mockImplementationOnce((_userId, _data) => Promise.resolve(undefined))
const { user } = renderPage()
await fillAndSubmitForm()
beforeEach(() => {
jest
.spyOn(API, "getAuthMethods")
.mockResolvedValue(MockAuthMethodsWithPasswordType)
jest.spyOn(API, "getUserLoginType").mockResolvedValue({
login_type: "password",
})
})
const expectedMessage = t("securityUpdateSuccessMessage", {
ns: "userSettingsPage",
})
const successMessage = await screen.findByText(expectedMessage)
expect(successMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
test("update password successfully", async () => {
jest
.spyOn(API, "updateUserPassword")
.mockImplementationOnce((_userId, _data) => Promise.resolve(undefined))
const { user } = await renderPage()
fillAndSubmitSecurityForm()
await waitFor(() => expect(window.location).toBeAt("/"))
})
const expectedMessage = t("securityUpdateSuccessMessage", {
ns: "userSettingsPage",
})
const successMessage = await screen.findByText(expectedMessage)
expect(successMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
await waitFor(() => expect(window.location).toBeAt("/"))
})
test("update password with incorrect old password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Incorrect password.",
validations: [{ detail: "Incorrect password.", field: "old_password" }],
}),
)
const { user } = await renderPage()
fillAndSubmitSecurityForm()
const errorMessage = await screen.findAllByText("Incorrect password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
})
test("update password with invalid password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
)
const { user } = await renderPage()
fillAndSubmitSecurityForm()
const errorMessage = await screen.findAllByText("Invalid password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
})
test("update password when submit returns an unknown error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",
})
describe("when the old_password is incorrect", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Incorrect password.",
validations: [
{ detail: "Incorrect password.", field: "old_password" },
],
}),
)
const { user } = await renderPage()
fillAndSubmitSecurityForm()
const { user } = renderPage()
await fillAndSubmitForm()
const errorText = t("warningsAndErrors.somethingWentWrong", {
ns: "common",
})
const errorMessage = await screen.findByText(errorText)
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
})
const errorMessage = await screen.findAllByText("Incorrect password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
test("change login type to OIDC", async () => {
const user = userEvent.setup()
const { user: userData } = await renderPage()
const convertToOAUTHSpy = jest
.spyOn(API, "convertToOAUTH")
.mockResolvedValue({
state_string: "some-state-string",
expires_at: "2021-01-01T00:00:00Z",
to_type: "oidc",
user_id: userData.id,
} as OAuthConversionResponse)
jest.spyOn(SSO, "redirectToOIDCAuth").mockImplementation(() => {
// Does a noop
})
describe("when the password is invalid", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
)
const ssoSection = screen.getByTestId("sso-section")
const githubButton = within(ssoSection).getByText("GitHub", { exact: false })
await user.click(githubButton)
const { user } = renderPage()
await fillAndSubmitForm()
const confirmationDialog = await screen.findByTestId("dialog")
const confirmPasswordField = within(confirmationDialog).getByLabelText(
"Confirm your password",
)
await user.type(confirmPasswordField, "password123")
const updateButton = within(confirmationDialog).getByText("Update")
await user.click(updateButton)
const errorMessage = await screen.findAllByText("Invalid password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
})
describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorText = t("warningsAndErrors.somethingWentWrong", {
ns: "common",
})
const errorMessage = await screen.findByText(errorText)
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
await waitFor(() => {
expect(convertToOAUTHSpy).toHaveBeenCalledWith({
password: "password123",
to_type: "github",
})
})
})
@@ -1,13 +1,17 @@
import { useMachine } from "@xstate/react"
import { useMe } from "hooks/useMe"
import { FC } from "react"
import { ComponentProps, FC } from "react"
import { userSecuritySettingsMachine } from "xServices/userSecuritySettings/userSecuritySettingsXService"
import { Section } from "../../../components/SettingsLayout/Section"
import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
export const Language = {
title: "Security",
}
import { useQuery } from "@tanstack/react-query"
import { getAuthMethods, getUserLoginType } from "api/api"
import {
SingleSignOnSection,
useSingleSignOnSection,
} from "./SingleSignOnSection"
import { Loader } from "components/Loader/Loader"
import { Stack } from "components/Stack/Stack"
export const SecurityPage: FC = () => {
const me = useMe()
@@ -20,21 +24,68 @@ export const SecurityPage: FC = () => {
},
)
const { error } = securityState.context
const { data: authMethods } = useQuery({
queryKey: ["authMethods"],
queryFn: getAuthMethods,
})
const { data: userLoginType } = useQuery({
queryKey: ["loginType"],
queryFn: getUserLoginType,
})
const singleSignOnSection = useSingleSignOnSection()
if (!authMethods || !userLoginType) {
return <Loader />
}
return (
<Section title={Language.title} description="Update your account password">
<SecurityForm
updateSecurityError={error}
isLoading={securityState.matches("updatingSecurity")}
initialValues={{ old_password: "", password: "", confirm_password: "" }}
onSubmit={(data) => {
securitySend({
type: "UPDATE_SECURITY",
data,
})
}}
/>
</Section>
<SecurityPageView
security={{
form: {
disabled: userLoginType.login_type !== "password",
error,
isLoading: securityState.matches("updatingSecurity"),
onSubmit: (data) => {
securitySend({
type: "UPDATE_SECURITY",
data,
})
},
},
}}
oidc={
authMethods.convert_to_oidc_enabled
? {
section: {
authMethods,
userLoginType,
...singleSignOnSection,
},
}
: undefined
}
/>
)
}
export const SecurityPageView = ({
security,
oidc,
}: {
security: {
form: ComponentProps<typeof SecurityForm>
}
oidc?: {
section: ComponentProps<typeof SingleSignOnSection>
}
}) => {
return (
<Stack spacing={6}>
<Section title="Security" description="Update your account password">
<SecurityForm {...security.form} />
</Section>
{oidc && <SingleSignOnSection {...oidc.section} />}
</Stack>
)
}
@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react"
import { SecurityPageView } from "./SecurityPage"
import { action } from "@storybook/addon-actions"
import {
MockAuthMethods,
MockAuthMethodsWithPasswordType,
} from "testHelpers/entities"
import { ComponentProps } from "react"
import set from "lodash/fp/set"
const defaultArgs: ComponentProps<typeof SecurityPageView> = {
security: {
form: {
disabled: false,
error: undefined,
isLoading: false,
onSubmit: action("onSubmit"),
},
},
oidc: {
section: {
userLoginType: {
login_type: "password",
},
authMethods: MockAuthMethods,
closeConfirmation: action("closeConfirmation"),
confirm: action("confirm"),
error: undefined,
isConfirming: false,
isUpdating: false,
openConfirmation: action("openConfirmation"),
},
},
}
const meta: Meta<typeof SecurityPageView> = {
title: "pages/SecurityPageView",
component: SecurityPageView,
args: defaultArgs,
}
export default meta
type Story = StoryObj<typeof SecurityPageView>
export const UsingOIDC: Story = {}
export const NoOIDCAvailable: Story = {
args: {
...defaultArgs,
oidc: undefined,
},
}
export const UserLoginTypeIsPassword: Story = {
args: set(
"oidc.section.authMethods",
MockAuthMethodsWithPasswordType,
defaultArgs,
),
}
export const ConfirmingOIDCConversion: Story = {
args: set(
"oidc.section",
{
...defaultArgs.oidc?.section,
authMethods: MockAuthMethodsWithPasswordType,
isConfirming: true,
},
defaultArgs,
),
}
@@ -0,0 +1,260 @@
import { useState } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import TextField from "@mui/material/TextField"
import Box from "@mui/material/Box"
import GitHubIcon from "@mui/icons-material/GitHub"
import KeyIcon from "@mui/icons-material/VpnKey"
import Button from "@mui/material/Button"
import { useLocation } from "react-router-dom"
import { retrieveRedirect } from "utils/redirect"
import Typography from "@mui/material/Typography"
import { convertToOAUTH } from "api/api"
import { AuthMethods, LoginType, UserLoginType } from "api/typesGenerated"
import Skeleton from "@mui/material/Skeleton"
import { Stack } from "components/Stack/Stack"
import { useMutation } from "@tanstack/react-query"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { getErrorMessage } from "api/errors"
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"
type LoginTypeConfirmation =
| {
open: false
selectedType: undefined
}
| {
open: true
selectedType: LoginType
}
export const redirectToOIDCAuth = (stateString: string, redirectTo: string) => {
window.location.href = `/api/v2/users/oidc/callback?oidc_merge_state=${stateString}&redirect=${redirectTo}`
}
export const useSingleSignOnSection = () => {
const location = useLocation()
const redirectTo = retrieveRedirect(location.search)
const [loginTypeConfirmation, setLoginTypeConfirmation] =
useState<LoginTypeConfirmation>({ open: false, selectedType: undefined })
const mutation = useMutation(convertToOAUTH, {
onSuccess: (data) => {
redirectToOIDCAuth(data.state_string, encodeURIComponent(redirectTo))
},
})
const openConfirmation = (selectedType: LoginType) => {
setLoginTypeConfirmation({ open: true, selectedType })
}
const closeConfirmation = () => {
setLoginTypeConfirmation({ open: false, selectedType: undefined })
mutation.reset()
}
const confirm = (password: string) => {
if (!loginTypeConfirmation.selectedType) {
throw new Error("No login type selected")
}
mutation.mutate({
to_type: loginTypeConfirmation.selectedType,
password,
})
}
return {
openConfirmation,
closeConfirmation,
confirm,
// We still want to show it loading when it is success so the modal does not
// change until the redirect
isUpdating: mutation.isLoading || mutation.isSuccess,
isConfirming: loginTypeConfirmation.open,
error: mutation.error,
}
}
type SingleSignOnSectionProps = ReturnType<typeof useSingleSignOnSection> & {
authMethods: AuthMethods
userLoginType: UserLoginType
}
export const SingleSignOnSection = ({
authMethods,
userLoginType,
openConfirmation,
closeConfirmation,
confirm,
isUpdating,
isConfirming,
error,
}: SingleSignOnSectionProps) => {
return (
<>
<Section
id="sso-section"
title="Single Sign On"
description="Authenticate in Coder using one-click"
>
<Box display="grid" gap="16px">
{authMethods && userLoginType ? (
userLoginType.login_type === "password" ? (
<>
{authMethods.github.enabled && (
<Button
disabled={isUpdating}
onClick={() => openConfirmation("github")}
startIcon={<GitHubIcon sx={{ width: 16, height: 16 }} />}
fullWidth
size="large"
>
GitHub
</Button>
)}
{authMethods.oidc.enabled && (
<Button
size="large"
startIcon={<OIDCIcon authMethods={authMethods} />}
fullWidth
disabled={isUpdating}
onClick={() => openConfirmation("oidc")}
>
{getOIDCLabel(authMethods)}
</Button>
)}
</>
) : (
<Box
sx={{
background: (theme) => theme.palette.background.paper,
borderRadius: 1,
border: (theme) => `1px solid ${theme.palette.divider}`,
padding: 2,
display: "flex",
gap: 2,
alignItems: "center",
fontSize: 14,
}}
>
<CheckCircleOutlined
sx={{
color: (theme) => theme.palette.success.light,
fontSize: 16,
}}
/>
<span>
Authenticated with{" "}
<strong>
{userLoginType.login_type === "github"
? "GitHub"
: getOIDCLabel(authMethods)}
</strong>
</span>
<Box sx={{ ml: "auto", lineHeight: 1 }}>
{userLoginType.login_type === "github" ? (
<GitHubIcon sx={{ width: 16, height: 16 }} />
) : (
<OIDCIcon authMethods={authMethods} />
)}
</Box>
</Box>
)
) : (
<Skeleton
variant="rectangular"
sx={{ height: 40, borderRadius: 1 }}
/>
)}
</Box>
</Section>
<ConfirmLoginTypeChangeModal
open={isConfirming}
error={error}
loading={isUpdating}
onClose={closeConfirmation}
onConfirm={confirm}
/>
</>
)
}
const OIDCIcon = ({ authMethods }: { authMethods: AuthMethods }) => {
return authMethods.oidc.iconUrl ? (
<Box
component="img"
alt="Open ID Connect icon"
src={authMethods.oidc.iconUrl}
sx={{ width: 16, height: 16 }}
/>
) : (
<KeyIcon sx={{ width: 16, height: 16 }} />
)
}
const getOIDCLabel = (authMethods: AuthMethods) => {
return authMethods.oidc.signInText || "OpenID Connect"
}
const ConfirmLoginTypeChangeModal = ({
open,
loading,
error,
onClose,
onConfirm,
}: {
open: boolean
loading: boolean
error: unknown
onClose: () => void
onConfirm: (password: string) => void
}) => {
const [password, setPassword] = useState("")
const handleConfirm = () => {
onConfirm(password)
}
return (
<ConfirmDialog
open={open}
onClose={() => {
onClose()
}}
onConfirm={handleConfirm}
hideCancel={false}
cancelText="Cancel"
confirmText="Update"
title="Change login type"
confirmLoading={loading}
description={
<Stack>
<Typography>
After changing your login type, you will not be able to change it
again. Are you sure you want to proceed and change your login type?
</Typography>
<TextField
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter") {
handleConfirm()
}
}}
error={Boolean(error)}
helperText={
error
? getErrorMessage(error, "Your password is incorrect")
: undefined
}
name="confirm-password"
id="confirm-password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
label="Confirm your password"
type="password"
/>
</Stack>
}
/>
)
}
+43
View File
@@ -1023,6 +1023,13 @@ export const MockAuthMethods: TypesGen.AuthMethods = {
password: { enabled: true },
github: { enabled: false },
oidc: { enabled: false, signInText: "", iconUrl: "" },
convert_to_oidc_enabled: true,
}
export const MockAuthMethodsWithPasswordType: TypesGen.AuthMethods = {
...MockAuthMethods,
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },
}
export const MockGitSSHKey: TypesGen.GitSSHKey = {
@@ -1507,6 +1514,42 @@ export const MockAuditLogGitSSH: TypesGen.AuditLog = {
},
}
export const MockAuditOauthConvert: TypesGen.AuditLog = {
...MockAuditLog,
resource_type: "convert_login",
resource_target: "oidc",
action: "create",
status_code: 201,
description: "{user} created login type conversion to {target}}",
diff: {
created_at: {
old: "0001-01-01T00:00:00Z",
new: "2023-06-20T20:44:54.243019Z",
secret: false,
},
expires_at: {
old: "0001-01-01T00:00:00Z",
new: "2023-06-20T20:49:54.243019Z",
secret: false,
},
state_string: {
old: "",
new: "",
secret: true,
},
to_type: {
old: "",
new: "oidc",
secret: false,
},
user_id: {
old: "",
new: "dc790496-eaec-4f88-a53f-8ce1f61a1fff",
secret: false,
},
},
}
export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = {
...MockAuditLog,
resource_type: "api_key",
+8
View File
@@ -134,6 +134,14 @@ export const handlers = [
rest.post("/api/v2/users", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUser))
}),
rest.get("/api/v2/users/:userid/login-type", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
login_type: "password",
}),
)
}),
rest.get("/api/v2/users/me/organizations", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockOrganization]))
}),