mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Bruno Quaresma
parent
357f3b38f7
commit
b5f26d9bdf
@@ -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> => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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]))
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user