diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 90c669b04d6ab..32d80012bbc08 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -9,7 +9,10 @@ beforeAll(() => // Reset any request handlers that we may add during the tests, // so they don't affect other tests. -afterEach(() => server.resetHandlers()) +afterEach(() => { + server.resetHandlers() + jest.clearAllMocks() +}) // Clean up after the tests are finished. afterAll(() => server.close()) diff --git a/site/src/api/errors.test.ts b/site/src/api/errors.test.ts new file mode 100644 index 0000000000000..4037f55dfeebc --- /dev/null +++ b/site/src/api/errors.test.ts @@ -0,0 +1,38 @@ +import { isApiError, mapApiErrorToFieldErrors } from "./errors" + +describe("isApiError", () => { + it("returns true when the object is an API Error", () => { + expect( + isApiError({ + isAxiosError: true, + response: { + data: { + message: "Invalid entry", + errors: [{ detail: "Username is already in use", field: "username" }], + }, + }, + }), + ).toBe(true) + }) + + it("returns false when the object is Error", () => { + expect(isApiError(new Error())).toBe(false) + }) + + it("returns false when the object is undefined", () => { + expect(isApiError(undefined)).toBe(false) + }) +}) + +describe("mapApiErrorToFieldErrors", () => { + it("returns correct field errors", () => { + expect( + mapApiErrorToFieldErrors({ + message: "Invalid entry", + errors: [{ detail: "Username is already in use", field: "username" }], + }), + ).toEqual({ + username: "Username is already in use", + }) + }) +}) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts new file mode 100644 index 0000000000000..c05a0b2da5f22 --- /dev/null +++ b/site/src/api/errors.ts @@ -0,0 +1,46 @@ +import axios, { AxiosError, AxiosResponse } from "axios" + +export const Language = { + errorsByCode: { + defaultErrorCode: "Invalid value", + }, +} + +interface FieldError { + field: string + detail: string +} + +type FieldErrors = Record + +export interface ApiErrorResponse { + message: string + errors?: FieldError[] +} + +export type ApiError = AxiosError & { response: AxiosResponse } + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any +export const isApiError = (err: any): err is ApiError => { + if (axios.isAxiosError(err)) { + const response = err.response?.data + + return ( + typeof response.message === "string" && (typeof response.errors === "undefined" || Array.isArray(response.errors)) + ) + } + + return false +} + +export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => { + const result: FieldErrors = {} + + if (apiErrorResponse.errors) { + for (const error of apiErrorResponse.errors) { + result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode + } + } + + return result +} diff --git a/site/src/api/index.ts b/site/src/api/index.ts index c6c28cff273c9..7d2b664f357fa 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -1,6 +1,6 @@ import axios, { AxiosRequestHeaders } from "axios" import { mutate } from "swr" -import { MockPager, MockUser, MockUser2 } from "../test_helpers" +import { MockPager, MockUser, MockUser2 } from "../test_helpers/entities" import * as Types from "./types" const CONTENT_TYPE_JSON: AxiosRequestHeaders = { @@ -103,3 +103,8 @@ export const putWorkspaceAutostop = async ( headers: { ...CONTENT_TYPE_JSON }, }) } + +export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/profile`, data) + return response.data +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 05c3a5cf07355..7b95a64743174 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -15,6 +15,7 @@ export interface UserResponse { readonly username: string readonly email: string readonly created_at: string + readonly name: string } /** @@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest { export interface WorkspaceAutostopRequest { schedule: string } + +export interface UpdateProfileRequest { + readonly username: string + readonly email: string + readonly name: string +} diff --git a/site/src/components/Form/index.ts b/site/src/components/Form/index.ts index df13aff05b06d..3eae433c00162 100644 --- a/site/src/components/Form/index.ts +++ b/site/src/components/Form/index.ts @@ -17,10 +17,10 @@ interface FormHelpers { helperText?: string } -export const getFormHelpers = (form: FormikContextType, name: string): FormHelpers => { +export const getFormHelpers = (form: FormikContextType, name: string, error?: string): FormHelpers => { // getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work const touched = getIn(form.touched, name) - const errors = getIn(form.errors, name) + const errors = error ?? getIn(form.errors, name) return { ...form.getFieldProps(name), id: name, diff --git a/site/src/components/Page/RequireAuth.tsx b/site/src/components/Page/RequireAuth.tsx index 011d958146018..43343e52a50f7 100644 --- a/site/src/components/Page/RequireAuth.tsx +++ b/site/src/components/Page/RequireAuth.tsx @@ -15,7 +15,7 @@ export const RequireAuth: React.FC = ({ children }) => { const location = useLocation() const redirectTo = embedRedirect(location.pathname) - if (authState.matches("signedOut") || !authState.context.me) { + if (authState.matches("signedOut")) { return } else if (authState.hasTag("loading")) { return diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx new file mode 100644 index 0000000000000..b851322530d3b --- /dev/null +++ b/site/src/components/Preferences/AccountForm.tsx @@ -0,0 +1,93 @@ +import FormHelperText from "@material-ui/core/FormHelperText" +import TextField from "@material-ui/core/TextField" +import { FormikContextType, FormikErrors, useFormik } from "formik" +import React from "react" +import * as Yup from "yup" +import { getFormHelpers, onChangeTrimmed } from "../Form" +import { Stack } from "../Stack/Stack" +import { LoadingButton } from "./../Button" + +interface AccountFormValues { + name: string + email: string + username: string +} + +export const Language = { + nameLabel: "Name", + usernameLabel: "Username", + emailLabel: "Email", + emailInvalid: "Please enter a valid email address.", + emailRequired: "Please enter an email address.", + updatePreferences: "Update preferences", +} + +const validationSchema = Yup.object({ + email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired), + name: Yup.string().optional(), + username: Yup.string().trim(), +}) + +export type AccountFormErrors = FormikErrors +export interface AccountFormProps { + isLoading: boolean + initialValues: AccountFormValues + onSubmit: (values: AccountFormValues) => void + formErrors?: AccountFormErrors + error?: string +} + +export const AccountForm: React.FC = ({ + isLoading, + onSubmit, + initialValues, + formErrors = {}, + error, +}) => { + const form: FormikContextType = useFormik({ + initialValues, + validationSchema, + onSubmit, + }) + + return ( + <> +
+ + (form, "name")} + autoFocus + autoComplete="name" + fullWidth + label={Language.nameLabel} + variant="outlined" + /> + (form, "email", formErrors.email)} + onChange={onChangeTrimmed(form)} + autoComplete="email" + fullWidth + label={Language.emailLabel} + variant="outlined" + /> + (form, "username", formErrors.username)} + onChange={onChangeTrimmed(form)} + autoComplete="username" + fullWidth + label={Language.usernameLabel} + variant="outlined" + /> + + {error && {error}} + +
+ + {isLoading ? "" : Language.updatePreferences} + +
+
+
+ + ) +} diff --git a/site/src/components/Stack/Stack.stories.tsx b/site/src/components/Stack/Stack.stories.tsx new file mode 100644 index 0000000000000..b10df77d80369 --- /dev/null +++ b/site/src/components/Stack/Stack.stories.tsx @@ -0,0 +1,22 @@ +import TextField from "@material-ui/core/TextField" +import { Story } from "@storybook/react" +import React from "react" +import { Stack, StackProps } from "./Stack" + +export default { + title: "Components/Stack", + component: Stack, +} + +const Template: Story = (args: StackProps) => ( + + + + + +) + +export const Example = Template.bind({}) +Example.args = { + spacing: 2, +} diff --git a/site/src/components/Stack/Stack.tsx b/site/src/components/Stack/Stack.tsx new file mode 100644 index 0000000000000..ed1015d9815de --- /dev/null +++ b/site/src/components/Stack/Stack.tsx @@ -0,0 +1,19 @@ +import { makeStyles } from "@material-ui/core/styles" +import React from "react" + +export interface StackProps { + spacing?: number +} + +const useStyles = makeStyles((theme) => ({ + stack: { + display: "flex", + flexDirection: "column", + gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing), + }, +})) + +export const Stack: React.FC = ({ children, spacing = 2 }) => { + const styles = useStyles({ spacing }) + return
{children}
+} diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx new file mode 100644 index 0000000000000..a99aad3a38d83 --- /dev/null +++ b/site/src/pages/preferences/account.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react" +import React from "react" +import * as API from "../../api" +import * as AccountForm from "../../components/Preferences/AccountForm" +import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar" +import { renderWithAuth } from "../../test_helpers" +import * as AuthXService from "../../xServices/auth/authXService" +import { Language, PreferencesAccountPage } from "./account" + +const renderPage = () => { + return renderWithAuth( + <> + + + , + ) +} + +const newData = { + name: "User", + email: "user@coder.com", + username: "user", +} + +const fillAndSubmitForm = async () => { + await waitFor(() => screen.findByLabelText("Name")) + fireEvent.change(screen.getByLabelText("Name"), { target: { value: newData.name } }) + fireEvent.change(screen.getByLabelText("Email"), { target: { value: newData.email } }) + fireEvent.change(screen.getByLabelText("Username"), { target: { value: newData.username } }) + fireEvent.click(screen.getByText(AccountForm.Language.updatePreferences)) +} + +describe("PreferencesAccountPage", () => { + describe("when it is a success", () => { + it("shows the success message", async () => { + jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) => + Promise.resolve({ + id: userId, + ...data, + created_at: new Date().toString(), + }), + ) + const { user } = renderPage() + await fillAndSubmitForm() + + const successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate) + expect(successMessage).toBeDefined() + expect(API.updateProfile).toBeCalledTimes(1) + expect(API.updateProfile).toBeCalledWith(user.id, newData) + }) + }) + + describe("when the email is already taken", () => { + it("shows an error", async () => { + jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ + isAxiosError: true, + response: { + data: { message: "Invalid profile", errors: [{ detail: "Email is already in use", field: "email" }] }, + }, + }) + + const { user } = renderPage() + await fillAndSubmitForm() + + const errorMessage = await screen.findByText("Email is already in use") + expect(errorMessage).toBeDefined() + expect(API.updateProfile).toBeCalledTimes(1) + expect(API.updateProfile).toBeCalledWith(user.id, newData) + }) + }) + + describe("when the username is already taken", () => { + it("shows an error", async () => { + jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ + isAxiosError: true, + response: { + data: { message: "Invalid profile", errors: [{ detail: "Username is already in use", field: "username" }] }, + }, + }) + + const { user } = renderPage() + await fillAndSubmitForm() + + const errorMessage = await screen.findByText("Username is already in use") + expect(errorMessage).toBeDefined() + expect(API.updateProfile).toBeCalledTimes(1) + expect(API.updateProfile).toBeCalledWith(user.id, newData) + }) + }) + + describe("when it is an unknown error", () => { + it("shows a generic error message", async () => { + jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ + data: "unknown error", + }) + + const { user } = renderPage() + await fillAndSubmitForm() + + const errorMessage = await screen.findByText(Language.unknownError) + expect(errorMessage).toBeDefined() + expect(API.updateProfile).toBeCalledTimes(1) + expect(API.updateProfile).toBeCalledWith(user.id, newData) + }) + }) +}) diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 20cd380c5adc9..fd507337234a4 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -1,11 +1,43 @@ -import React from "react" +import { useActor } from "@xstate/react" +import React, { useContext } from "react" +import { isApiError, mapApiErrorToFieldErrors } from "../../api/errors" +import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" +import { XServiceContext } from "../../xServices/StateContext" -const Language = { +export const Language = { title: "Account", - description: "Update your display name, email, profile picture, and dotfiles preferences.", + description: "Update your display name, email, and username.", + unknownError: "Oops, an unknown error occurred.", } export const PreferencesAccountPage: React.FC = () => { - return
+ const xServices = useContext(XServiceContext) + const [authState, authSend] = useActor(xServices.authXService) + const { me, updateProfileError } = authState.context + const hasError = !!updateProfileError + const formErrors = + hasError && isApiError(updateProfileError) ? mapApiErrorToFieldErrors(updateProfileError.response.data) : undefined + const hasUnknownError = hasError && !isApiError(updateProfileError) + + if (!me) { + throw new Error("No current user found") + } + + return ( +
+ { + authSend({ + type: "UPDATE_PROFILE", + data, + }) + }} + /> +
+ ) } diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index bdff6266861ad..bdccc8bb806ac 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -20,6 +20,7 @@ export const MockBuildInfo: BuildInfoResponse = { } export const MockUser: UserResponse = { + name: "Test User", id: "test-user", username: "TestUser", email: "test@coder.com", @@ -28,6 +29,7 @@ export const MockUser: UserResponse = { export const MockUser2: UserResponse = { id: "test-user-2", + name: "Test User 2", username: "TestUser2", email: "test2@coder.com", created_at: "", diff --git a/site/src/test_helpers/index.tsx b/site/src/test_helpers/index.tsx index 4da5fd57587bb..419484c9c3ea0 100644 --- a/site/src/test_helpers/index.tsx +++ b/site/src/test_helpers/index.tsx @@ -2,9 +2,11 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider" import { render as wrappedRender, RenderResult } from "@testing-library/react" import { createMemoryHistory } from "history" import React from "react" -import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom" +import { MemoryRouter, Route, Routes, unstable_HistoryRouter as HistoryRouter } from "react-router-dom" +import { RequireAuth } from "../components" import { dark } from "../theme" import { XServiceProvider } from "../xServices/StateContext" +import { MockUser } from "./entities" export const history = createMemoryHistory() @@ -22,4 +24,23 @@ export const render = (component: React.ReactElement): RenderResult => { return wrappedRender({component}) } +type RenderWithAuthResult = RenderResult & { user: typeof MockUser } + +export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult { + const renderResult = wrappedRender( + + + + {ui}} /> + + + , + ) + + return { + user: MockUser, + ...renderResult, + } +} + export * from "./entities" diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index ea74bea36d4d0..4dddf9f901ab0 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -1,19 +1,28 @@ import { assign, createMachine } from "xstate" import * as API from "../../api" import * as Types from "../../api/types" +import { displaySuccess } from "../../components/Snackbar" +export const Language = { + successProfileUpdate: "Updated preferences.", +} export interface AuthContext { getUserError?: Error | unknown authError?: Error | unknown + updateProfileError?: Error | unknown me?: Types.UserResponse } -export type AuthEvent = { type: "SIGN_OUT" } | { type: "SIGN_IN"; email: string; password: string } +export type AuthEvent = + | { type: "SIGN_OUT" } + | { type: "SIGN_IN"; email: string; password: string } + | { type: "UPDATE_PROFILE"; data: Types.UpdateProfileRequest } export const authMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QFdZgE4GUAuBDbYAdLAJZQB2kA8stgMSYCSA4gHID6jrioADgPalsJfuR4gAHogDM0gJyEATAHYAbNIAscxdunKArKo0AaEAE9EG+YX0BGABz3FD6atsbbc5QF9vp1Bg4+ESkFCTkUIzkdBCiROEAbvwA1iFk5FHiAkIiYkiSiHIADITSOvbSRRqq2m7StqYWCIr2+ja2tvVV+vqaRVW+-mhYeATE6eGR0Rjo-OiEvAA2+ABmcwC24xSZ+dkkwqLiUghlyoRF+opayq4X+sqK+o2Il6qEcq5y+hrfjt+qgxAARGwUIMGwwgiAFVhjE4oREikiOCALJgLKCfa5I4yIyEZTFZQaezFK5lPTPBC2IqKUqKC4EjqqKrFOSA4FBMbgyFQGEYOgzOYLZbYNboTao9G7TEHPKgY7SewlTQ3dRyVQ6GpVSmKMqEIz2ZRKjrKIqqJzs4actIUSBRBgsDhUKEAFQxOUO+WOhvshA0ahualsqnu8kpthUhGplVUIa+rUq9ktgVGNvIkxo9FilAR5CSqS25Ez7qxnvliCMGlK1Tk6vN5p+T3ML0Ub2U7iKXmcGg8tmTILGoXTEUzAvQs3mS1WG0LxelHrlBQQIbasc7aiKtnbV3sOpaUZXBjkwd1A0B5H4EDg4g5qcL1FoJdlOIQdlpH2qhmJzJ+DWbCB7WxSmUIl2wJM0CSTPwgStO8h0mHY+BlbEvReICaSMbQflkU0fkpHtfS3MC3C+aR9ENfR+2tMEwAhSY+XQJ8UPLACziVS5TXKAxPDI8MaSjIojSqPQezNRUqLg9I7UXPZn1Q5dqn1CMNEEi4HAecNIyVGoIweVQDH0tloNvUF4JHR951LRdvQcc4PCcAx12-fDOnOeRTU7C4SXsPtjNg4ImLLJddUIdiVBpOQKJ4psmh0ASQOPRUiI8RRfF8IA */ + /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogCMABgCcAFkLyZAVilS5GgEwAOAMwA2ADQgAnogDsa5YcOXD2y7oWH92-QF9PptFlz4RKQUJORQDOS0ECJEoQBufADWQWTkEWL8gsKiSBKI7kraqpZSuh7aUvpy6vqmFghlcoT6qgb6GsUlctrevhg4eATEqaHhkWAAThN8E4Q8ADb4AGYzALbDFOm5mSRCImKSCLKKynJqGlpSekZ1iIaltvaOzq4FvSB+A4GEMOhCYQBVWCTKIxQjxJJEX4AWTAGQEu2yB0QrikhG0Mn0+l0ulUWJkhlatXMKKqynkCn0lOscnsUnenwCQ1+-ygQJBk2mswWyzWPzA6Fh8Ky+1yh202iacip2I8hjU9m0twQ1lUzSk9hkHlUCjU2kMDP6TJSFEgETm0yWJHmsQgNtoAIACgARACCABUAKJsR0AJSoADEGAAZT3CxGi0CHGTKmSG-yDE2UCDmniW61EVA8CD4UaO9P26KUcHkBLJQiMxMbZOpguZ7O5sL5vhWm0ICEAY1zIgA2jIALrhvY5KPSKSWNWKWQeaUKSXj5UY3SEUrdKqExy08fxr5DYI18gWlsZwhZnOs5utsC0TkzOaLdArCbrSvffdmw9p48208Ni919tSz4Lthz7QdtgRYdkQaLRCF0SxtAUcdpQnRxdGVXV9EIKdFBkGRdDkSxFGKHdjWrD96GYdgqABd0hyRMVpCxdFaSxVQZEsIxx0sSxlUI9Eql1DUJQnRQ5FIqt91GGh0FBYsIXLfcZPoyM8gQdwsIQuRdEMSlSm1DxlR1Zc1AIhDdJwrwfA+I1JJGMIZJvKY7x5R8+SUjAVJHNSijVdwCIUdQriJfReJJBAihkZpLAUJDikigwJW8azyD4CA4DEV891SahPIgkVvMOdRDEINxqTUbFCW05UJywhQDFUNd2gxQxxOsrKk1GLZeEghjRyOfUoqkPEOOlQj2mVVrtDgjUEPYkpcT0CTvhZUZ2QmLzoLnZVdA1OCiS0TjcU0VRluy00U0-OtwTtIhUs9ZyNvyiNCrHHi4InPCFE4wktQwvbQtiloWu0jFLDOpMPyPK8bp-W8np6groI0d74PYmRvqMdilXC1wSvYxRYsIwbrHB9rbLfHLLuhk8SFuzbGKOYasLRr6fux5UZWi2KCclXarL6BNKYu2tv3rc88zrBn+pxTTGsqULp2xKRlRO0qCJnDdJXuMnBd3SHqa-K9pbUgBaRDlVNzjyTwjpOM+1QDXJoXzoPE3DlN+rLakVwba1dpvvYjQIeraS8sRl7oPcQgicQicihxGQfaM9jlFaWdSgJDR6Wd-X3cQZdY8DhPdCThRLY8dUOPsBwDE4wjdGSzwgA */ createMachine( { + context: { me: undefined, getUserError: undefined, authError: undefined, updateProfileError: undefined }, tsTypes: {} as import("./authXService.typegen").Typegen0, schema: { context: {} as AuthContext, @@ -25,20 +34,18 @@ export const authMachine = signIn: { data: Types.LoginResponse } + updateProfile: { + data: Types.UserResponse + } }, }, - context: { - me: undefined, - getUserError: undefined, - authError: undefined, - }, id: "authState", initial: "gettingUser", states: { signedOut: { on: { SIGN_IN: { - target: "#authState.signingIn", + target: "signingIn", }, }, }, @@ -48,14 +55,14 @@ export const authMachine = id: "signIn", onDone: [ { - target: "#authState.gettingUser", actions: "clearAuthError", + target: "gettingUser", }, ], onError: [ { actions: "assignAuthError", - target: "#authState.signedOut", + target: "signedOut", }, ], }, @@ -68,22 +75,60 @@ export const authMachine = onDone: [ { actions: ["assignMe", "clearGetUserError"], - target: "#authState.signedIn", + target: "signedIn", }, ], onError: [ { actions: "assignGetUserError", - target: "#authState.signedOut", + target: "signedOut", }, ], }, tags: "loading", }, signedIn: { + type: "parallel", + states: { + profile: { + initial: "idle", + states: { + idle: { + initial: "noError", + states: { + noError: {}, + error: {}, + }, + on: { + UPDATE_PROFILE: { + target: "updatingProfile", + }, + }, + }, + updatingProfile: { + entry: "clearUpdateProfileError", + invoke: { + src: "updateProfile", + onDone: [ + { + actions: ["assignMe", "notifySuccessProfileUpdate"], + target: "#authState.signedIn.profile.idle.noError", + }, + ], + onError: [ + { + actions: "assignUpdateProfileError", + target: "#authState.signedIn.profile.idle.error", + }, + ], + }, + }, + }, + }, + }, on: { SIGN_OUT: { - target: "#authState.signingOut", + target: "signingOut", }, }, }, @@ -94,13 +139,13 @@ export const authMachine = onDone: [ { actions: ["unassignMe", "clearAuthError"], - target: "#authState.signedOut", + target: "signedOut", }, ], onError: [ { actions: "assignAuthError", - target: "#authState.signedIn", + target: "signedIn", }, ], }, @@ -115,6 +160,13 @@ export const authMachine = }, signOut: API.logout, getMe: API.getUser, + updateProfile: async (context, event) => { + if (!context.me) { + throw new Error("No current user found") + } + + return API.updateProfile(context.me.id, event.data) + }, }, actions: { assignMe: assign({ @@ -138,6 +190,15 @@ export const authMachine = ...context, authError: undefined, })), + assignUpdateProfileError: assign({ + updateProfileError: (_, event) => event.data, + }), + notifySuccessProfileUpdate: () => { + displaySuccess(Language.successProfileUpdate) + }, + clearUpdateProfileError: assign({ + updateProfileError: (_) => undefined, + }), }, }, )