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, + }), }, }, ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy