diff --git a/.vscode/settings.json b/.vscode/settings.json index 1ae5f3419f969..1ff3ea0883482 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -211,6 +211,5 @@ "go.testFlags": ["-short", "-coverpkg=./..."], // We often use a version of TypeScript that's ahead of the version shipped // with VS Code. - "typescript.tsdk": "./site/node_modules/typescript/lib", - "prettier.prettierPath": "./node_modules/prettier" + "typescript.tsdk": "./site/node_modules/typescript/lib" } diff --git a/coderd/userauth.go b/coderd/userauth.go index e09b38cf3fe99..92e93474a8b51 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -840,7 +840,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // Convert the []interface{} we get to a []string. groupsInterface, ok := groupsRaw.([]interface{}) if ok { - logger.Debug(ctx, "groups returned in oidc claims", + api.Logger.Debug(ctx, "groups returned in oidc claims", slog.F("len", len(groupsInterface)), slog.F("groups", groupsInterface), ) @@ -861,7 +861,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { groups = append(groups, group) } } else { - logger.Debug(ctx, "groups field was an unknown type", + api.Logger.Debug(ctx, "groups field was an unknown type", slog.F("type", fmt.Sprintf("%T", groupsRaw)), ) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2ce560d22880c..0dff38cff4786 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -108,33 +108,12 @@ export const login = async ( return response.data } -export const convertToOauth = async ( - email: string, - password: string, - to_login_type: string, -): Promise => { - const payload = JSON.stringify({ - email, - password, - to_login_type, - }) - - try { - const response = await axios.post( - "/api/v2/users/convert-login", - payload, - { - headers: { ...CONTENT_TYPE_JSON }, - }, - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 401) { - return undefined - } - - throw error - } +export const convertToOAUTH = async (request: TypesGen.ConvertLoginRequest) => { + const response = await axios.post( + "/api/v2/users/convert-login", + request, + ) + return response.data } export const logout = async (): Promise => { diff --git a/site/src/components/SettingsAccountForm/SettingsAccountForm.test.tsx b/site/src/components/SettingsAccountForm/SettingsAccountForm.test.tsx index df5268883ad2e..000e6a8adc3fc 100644 --- a/site/src/components/SettingsAccountForm/SettingsAccountForm.test.tsx +++ b/site/src/components/SettingsAccountForm/SettingsAccountForm.test.tsx @@ -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() }) diff --git a/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx b/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx index 1525e1ecb37a9..0141146e8f84b 100644 --- a/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx +++ b/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx @@ -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> = ({ return ( <> -
- + + {Boolean(updateProfileError) && ( )} @@ -91,8 +91,8 @@ export const AccountForm: FC> = ({ {isLoading ? "" : Language.updateSettings} - -
+ + ) } diff --git a/site/src/components/SettingsLayout/Section.tsx b/site/src/components/SettingsLayout/Section.tsx index 29e761fdec030..35419ff4ab89d 100644 --- a/site/src/components/SettingsLayout/Section.tsx +++ b/site/src/components/SettingsLayout/Section.tsx @@ -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> & { } export const Section: SectionFC = ({ + id, title, description, toolbar, @@ -30,12 +33,16 @@ export const Section: SectionFC = ({ }) => { const styles = useStyles({ layout }) return ( -
+
{(title || description) && (
- {title && {title}} + {title && ( + + {title} + + )} {description && typeof description === "string" && ( {description} diff --git a/site/src/components/SettingsSecurityForm/SettingsSecurityForm.stories.tsx b/site/src/components/SettingsSecurityForm/SettingsSecurityForm.stories.tsx index d2ca25ff16bd0..506b81270eebc 100644 --- a/site/src/components/SettingsSecurityForm/SettingsSecurityForm.stories.tsx +++ b/site/src/components/SettingsSecurityForm/SettingsSecurityForm.stories.tsx @@ -17,12 +17,6 @@ const Template: Story = (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, - }, } diff --git a/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx b/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx index 0335b4cfd77c6..39230f3837b1b 100644 --- a/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx +++ b/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx @@ -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 + error?: unknown } export const SecurityForm: FC = ({ + disabled, isLoading, onSubmit, - initialValues, - updateSecurityError, - initialTouched, + error, }) => { const form: FormikContextType = useFormik({ - initialValues, + initialValues: { + old_password: "", + password: "", + confirm_password: "", + }, validationSchema, onSubmit, - initialTouched, }) - const getFieldHelpers = getFormHelpers( - form, - updateSecurityError, - ) + const getFieldHelpers = getFormHelpers(form, error) + + if (disabled) { + return ( + + Password changes are only allowed for password based accounts. + + ) + } return ( <> -
- - {Boolean(updateSecurityError) && ( - - )} + + + {Boolean(error) && } = ({ {isLoading ? "" : Language.updatePassword}
- - + + ) } diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx index 9a809d4b57602..eeb9a5eb639b6 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx @@ -1,116 +1,144 @@ -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" const { t } = i18next -const renderPage = () => { - return renderWithAuth() +const renderPage = async () => { + const utils = renderWithAuth() + 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() - - 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) - - await waitFor(() => expect(window.location).toBeAt("/")) - }) +beforeEach(() => { + jest + .spyOn(API, "getAuthMethods") + .mockResolvedValue(MockAuthMethodsWithPasswordType) +}) + +test("update password successfully", async () => { + jest + .spyOn(API, "updateUserPassword") + .mockImplementationOnce((_userId, _data) => Promise.resolve(undefined)) + const { user } = await renderPage() + fillAndSubmitSecurityForm() + + 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) - 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 } = renderPage() - await fillAndSubmitForm() - - 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) - }) + 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 password is invalid", () => { - it("shows an error", async () => { - jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce( - mockApiError({ - message: "Invalid password.", - validations: [{ detail: "Invalid password.", field: "password" }], - }), - ) - - const { user } = renderPage() - await fillAndSubmitForm() - - 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) - }) + const { user } = await renderPage() + fillAndSubmitSecurityForm() + + 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) +}) - 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) +test("change login type to OIDC", async () => { + const convertToOAUTHSpy = jest.spyOn(API, "convertToOAUTH") + const user = userEvent.setup() + const { user: userData } = await renderPage() + + const ssoSection = screen.getByTestId("sso-section") + const githubButton = within(ssoSection).getByText("GitHub", { exact: false }) + await user.click(githubButton) + + 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) + + await waitFor(() => { + expect(convertToOAUTHSpy).toHaveBeenCalledWith({ + password: "password123", + to_login_type: "github", + email: userData.email, }) }) }) diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx index 75fa1290636bd..45361684efec9 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx @@ -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 } 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,63 @@ export const SecurityPage: FC = () => { }, ) const { error } = securityState.context + const { data: authMethods } = useQuery({ + queryKey: ["authMethods"], + queryFn: getAuthMethods, + }) + const singleSignOnSection = useSingleSignOnSection() + + if (!authMethods) { + return + } + + return ( + { + securitySend({ + type: "UPDATE_SECURITY", + data, + }) + }, + }, + }} + oidc={ + authMethods.convert_to_oidc_enabled + ? { + section: { + authMethods, + ...singleSignOnSection, + }, + } + : undefined + } + /> + ) +} +export const SecurityPageView = ({ + security, + oidc, +}: { + security: { + form: ComponentProps + } + oidc?: { + section: ComponentProps + } +}) => { return ( -
- { - securitySend({ - type: "UPDATE_SECURITY", - data, - }) - }} - /> -
+ +
+ +
+ {oidc && } +
) } diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPageView.stories.tsx new file mode 100644 index 0000000000000..09a34d5ac6f84 --- /dev/null +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPageView.stories.tsx @@ -0,0 +1,69 @@ +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 = { + security: { + form: { + disabled: false, + error: undefined, + isLoading: false, + onSubmit: action("onSubmit"), + }, + }, + oidc: { + section: { + authMethods: MockAuthMethods, + closeConfirmation: action("closeConfirmation"), + confirm: action("confirm"), + error: undefined, + isConfirming: false, + isUpdating: false, + openConfirmation: action("openConfirmation"), + }, + }, +} + +const meta: Meta = { + title: "pages/SecurityPageView", + component: SecurityPageView, + args: defaultArgs, +} + +export default meta +type Story = StoryObj + +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, + ), +} diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx new file mode 100644 index 0000000000000..9e10c328ba183 --- /dev/null +++ b/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx @@ -0,0 +1,259 @@ +import { useState } from "react" +import { Section } from "../../../components/SettingsLayout/Section" +import { useMe } from "hooks/useMe" +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 } 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 useSingleSignOnSection = () => { + const me = useMe() + const location = useLocation() + const redirectTo = retrieveRedirect(location.search) + const [loginTypeConfirmation, setLoginTypeConfirmation] = + useState({ open: false, selectedType: undefined }) + + const mutation = useMutation(convertToOAUTH, { + onSuccess: (data) => { + window.location.href = `/api/v2/users/oidc/callback?oidc_merge_state=${ + data.state_string + }&redirect=${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_login_type: loginTypeConfirmation.selectedType, + email: me.email, + 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 & { + authMethods: AuthMethods +} + +export const SingleSignOnSection = ({ + authMethods, + openConfirmation, + closeConfirmation, + confirm, + isUpdating, + isConfirming, + error, +}: SingleSignOnSectionProps) => { + return ( + <> +
+ + {authMethods ? ( + authMethods.me_login_type === "password" ? ( + <> + {authMethods.github.enabled && ( + + )} + {authMethods.oidc.enabled && ( + + )} + + ) : ( + theme.palette.background.paper, + borderRadius: 1, + border: (theme) => `1px solid ${theme.palette.divider}`, + padding: 2, + display: "flex", + gap: 2, + alignItems: "center", + fontSize: 14, + }} + > + theme.palette.success.light, + fontSize: 16, + }} + /> + + Authenticated with{" "} + + {authMethods.me_login_type === "github" + ? "GitHub" + : getOIDCLabel(authMethods)} + + + + {authMethods.me_login_type === "github" ? ( + + ) : ( + + )} + + + ) + ) : ( + + )} + +
+ + + + ) +} + +const OIDCIcon = ({ authMethods }: { authMethods: AuthMethods }) => { + return authMethods.oidc.iconUrl ? ( + + ) : ( + + ) +} + +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 ( + { + onClose() + }} + onConfirm={handleConfirm} + hideCancel={false} + cancelText="Cancel" + confirmText="Update" + title="Change login type" + confirmLoading={loading} + description={ + + + 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? + + { + 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" + /> + + } + /> + ) +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 19db62cfec90f..870ddcf077b11 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1021,6 +1021,14 @@ 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, + me_login_type: "password", + github: { enabled: true }, + oidc: { enabled: true, signInText: "", iconUrl: "" }, } export const MockGitSSHKey: TypesGen.GitSSHKey = {