mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add the preferences/account page (#999)
This commit is contained in:
+4
-1
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<FieldError["field"], FieldError["detail"]>
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
message: string
|
||||
errors?: FieldError[]
|
||||
}
|
||||
|
||||
export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> }
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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<Types.UserResponse> => {
|
||||
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ interface FormHelpers {
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string): FormHelpers => {
|
||||
export const getFormHelpers = <T>(form: FormikContextType<T>, 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,
|
||||
|
||||
@@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const redirectTo = embedRedirect(location.pathname)
|
||||
|
||||
if (authState.matches("signedOut") || !authState.context.me) {
|
||||
if (authState.matches("signedOut")) {
|
||||
return <Navigate to={redirectTo} />
|
||||
} else if (authState.hasTag("loading")) {
|
||||
return <FullScreenLoader />
|
||||
|
||||
@@ -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<AccountFormValues>
|
||||
export interface AccountFormProps {
|
||||
isLoading: boolean
|
||||
initialValues: AccountFormValues
|
||||
onSubmit: (values: AccountFormValues) => void
|
||||
formErrors?: AccountFormErrors
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const AccountForm: React.FC<AccountFormProps> = ({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
formErrors = {},
|
||||
error,
|
||||
}) => {
|
||||
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
|
||||
initialValues,
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<Stack>
|
||||
<TextField
|
||||
{...getFormHelpers<AccountFormValues>(form, "name")}
|
||||
autoFocus
|
||||
autoComplete="name"
|
||||
fullWidth
|
||||
label={Language.nameLabel}
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
{...getFormHelpers<AccountFormValues>(form, "email", formErrors.email)}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoComplete="email"
|
||||
fullWidth
|
||||
label={Language.emailLabel}
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
{...getFormHelpers<AccountFormValues>(form, "username", formErrors.username)}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoComplete="username"
|
||||
fullWidth
|
||||
label={Language.usernameLabel}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
|
||||
<div>
|
||||
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
|
||||
{isLoading ? "" : Language.updatePreferences}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<StackProps> = (args: StackProps) => (
|
||||
<Stack {...args}>
|
||||
<TextField autoFocus autoComplete="name" fullWidth label="Name" variant="outlined" />
|
||||
<TextField autoComplete="email" fullWidth label="Email" variant="outlined" />
|
||||
<TextField autoComplete="username" fullWidth label="Username" variant="outlined" />
|
||||
</Stack>
|
||||
)
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
spacing: 2,
|
||||
}
|
||||
@@ -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<StackProps> = ({ children, spacing = 2 }) => {
|
||||
const styles = useStyles({ spacing })
|
||||
return <div className={styles.stack}>{children}</div>
|
||||
}
|
||||
@@ -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(
|
||||
<>
|
||||
<PreferencesAccountPage />
|
||||
<GlobalSnackbar />
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 <Section title={Language.title} description={Language.description} />
|
||||
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 (
|
||||
<Section title={Language.title} description={Language.description}>
|
||||
<AccountForm
|
||||
error={hasUnknownError ? Language.unknownError : undefined}
|
||||
formErrors={formErrors}
|
||||
isLoading={authState.matches("signedIn.profile.updatingProfile")}
|
||||
initialValues={{ name: me.name, username: me.username, email: me.email }}
|
||||
onSubmit={(data) => {
|
||||
authSend({
|
||||
type: "UPDATE_PROFILE",
|
||||
data,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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(<WrapperComponent>{component}</WrapperComponent>)
|
||||
}
|
||||
|
||||
type RenderWithAuthResult = RenderResult & { user: typeof MockUser }
|
||||
|
||||
export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult {
|
||||
const renderResult = wrappedRender(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<XServiceProvider>
|
||||
<Routes>
|
||||
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
|
||||
</Routes>
|
||||
</XServiceProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
return {
|
||||
user: MockUser,
|
||||
...renderResult,
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./entities"
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user