Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🪟 🎉 Add username editing #14242

Merged
merged 8 commits into from
Aug 8, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export class UserService extends AirbyteRequestService {
return this.fetch<void>(`${this.url}/update`, params);
}

public async changeName(authUserId: string, userId: string, name: string): Promise<void> {
return this.fetch<void>(`${this.url}/update`, {
authUserId,
userId,
name,
});
}

public async changeEmail(email: string): Promise<void> {
return this.fetch<void>(`${this.url}/update`, {
email,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type UserStatus = "invited" | "registered" | "disabled";
export interface User {
email: string;
name: string;
authUserId: string;
userId: string;
status?: UserStatus;
intercomHash: string;
Expand Down
8 changes: 6 additions & 2 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@
"settings.accountSettings.firstName": "First name",
"settings.accountSettings.enterPassword": "Enter current password",
"settings.accountSettings.firstName.placeholder": "Christopher",
"settings.accountSettings.fullName": "Full name",
"settings.accountSettings.fullName.placeholder": "Christopher Smith",
"settings.accountSettings.name": "Full name",
"settings.accountSettings.name.placeholder": "Christopher Smith",
"settings.accountSettings.name.empty.error": "Name cannot be empty",
"settings.accountSettings.lastName": "Last name",
"settings.accountSettings.lastName.placeholder": "Smith",
"settings.accountSettings.updateName": "Update Name",
"settings.accountSettings.email": "Email",
"settings.accountSettings.updateEmail": "Update Email",
"settings.accountSettings.password": "Password",
Expand All @@ -75,6 +77,8 @@
"settings.accountSettings.updatePasswordError": "Password update failed: ",
"settings.accountSettings.updatePasswordSuccess": "Your password has been updated!",
"settings.accountSettings.updatePassword": "Update password",
"settings.accountSettings.updateNameError": "Name update failed: ",
"settings.accountSettings.updateNameSuccess": "Your name has been updated!",
"settings.userSettings": "User settings",
"settings.workspaceSettings": "Workspace settings",
"settings.generalSettings": "General Settings",
Expand Down
12 changes: 11 additions & 1 deletion airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type AuthSignUp = (form: {
}) => Promise<void>;

export type AuthChangeEmail = (email: string, password: string) => Promise<void>;
export type AuthChangeName = (name: string) => Promise<void>;

export type AuthSendEmailVerification = () => Promise<void>;
export type AuthVerifyEmail = (code: string) => Promise<void>;
Expand All @@ -48,6 +49,7 @@ interface AuthContextApi {
signUp: AuthSignUp;
updatePassword: AuthUpdatePassword;
updateEmail: AuthChangeEmail;
updateName: AuthChangeName;
requirePasswordReset: AuthRequirePasswordReset;
confirmPasswordReset: AuthConfirmPasswordReset;
sendEmailVerification: AuthSendEmailVerification;
Expand All @@ -58,7 +60,7 @@ interface AuthContextApi {
export const AuthContext = React.createContext<AuthContextApi | null>(null);

export const AuthenticationProvider: React.FC = ({ children }) => {
const [state, { loggedIn, emailVerified, authInited, loggedOut }] = useTypesafeReducer<
const [state, { loggedIn, emailVerified, authInited, loggedOut, updateUserName }] = useTypesafeReducer<
AuthServiceState,
typeof actions
>(authStateReducer, initialState, actions);
Expand Down Expand Up @@ -113,6 +115,14 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
queryClient.removeQueries();
loggedOut();
},
async updateName(name: string): Promise<void> {
if (!state.currentUser) {
return;
}
await userService.changeName(state.currentUser.authUserId, state.currentUser.userId, name);
await authService.updateProfile(name);
updateUserName({ value: name });
},
async updateEmail(email, password): Promise<void> {
await userService.changeEmail(email);
return authService.updateEmail(email, password);
Expand Down
13 changes: 13 additions & 0 deletions airbyte-webapp/src/packages/cloud/services/auth/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const actions = {
loggedIn: createAction("LOGGED_IN")<{ user: User; emailVerified: boolean }>(),
emailVerified: createAction("EMAIL_VERIFIED")<boolean>(),
loggedOut: createAction("LOGGED_OUT")<void>(),
updateUserName: createAction("UPDATE_USER_NAME")<{ value: string }>(),
};

type Actions = ActionType<typeof actions>;
Expand Down Expand Up @@ -57,4 +58,16 @@ export const authStateReducer = createReducer<AuthServiceState, Actions>(initial
emailVerified: false,
loggedOut: true,
};
})
.handleAction(actions.updateUserName, (state, action): AuthServiceState => {
if (!state.currentUser) {
return state;
}
Comment on lines +63 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in a separate comment, this should never happen if we check that the current user exists first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this error, so that was only workaround here

TS2322: Type '{ name: string; email?: string | undefined; authUserId?: string | undefined; userId?: string | undefined;
status?: UserStatus | undefined; intercomHash?: string | undefined; }' is not assignable to type 'User'. 
Types of property 'email' are incompatible. 
Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.

return {
...state,
currentUser: {
...state.currentUser,
name: action.payload.value,
},
};
});
Original file line number Diff line number Diff line change
@@ -1,64 +1,27 @@
import { Field, FieldProps, Form, Formik } from "formik";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
import { useMutation } from "react-query";
import styled from "styled-components";

import { LabeledInput, LoadingButton } from "components";
import { LoadingButton } from "components";

import { useAuthService, useCurrentUser } from "packages/cloud/services/auth/AuthService";
import { RowFieldItem } from "packages/cloud/views/auth/components/FormComponents";
import { Content, SettingsCard } from "pages/SettingsPage/pages/SettingsComponents";
import { useAuthService } from "packages/cloud/services/auth/AuthService";
import { SettingsCard } from "pages/SettingsPage/pages/SettingsComponents";

import { EmailSection, PasswordSection } from "./components";
import { EmailSection, PasswordSection, NameSection } from "./components";

const Header = styled.div`
display: flex;
justify-content: space-between;
`;

const AccountSettingsView: React.FC = () => {
const { formatMessage } = useIntl();
const authService = useAuthService();
const { mutateAsync: logout, isLoading: isLoggingOut } = useMutation(() => authService.logout());
const user = useCurrentUser();

return (
<>
<SettingsCard title={<FormattedMessage id="settings.account" />}>
<Content>
<Formik
initialValues={{
name: user.name,
}}
onSubmit={() => {
throw new Error("Not implemented");
}}
>
{() => (
<Form>
<RowFieldItem>
<Field name="name">
{({ field, meta }: FieldProps<string>) => (
<LabeledInput
{...field}
label={<FormattedMessage id="settings.accountSettings.fullName" />}
disabled
placeholder={formatMessage({
id: "settings.accountSettings.fullName.placeholder",
})}
type="text"
error={!!meta.error && meta.touched}
message={meta.touched && meta.error && formatMessage({ id: meta.error })}
/>
)}
</Field>
</RowFieldItem>
</Form>
)}
</Formik>
</Content>
</SettingsCard>
<NameSection />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it so much cleaner, thanks for extracting 🎉

<EmailSection />
<PasswordSection />
<SettingsCard
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Field, FieldProps, Form, Formik } from "formik";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";

import { LoadingButton } from "components";
import { LabeledInput } from "components/LabeledInput";

import { useCurrentUser } from "packages/cloud/services/auth/AuthService";
import { RowFieldItem } from "packages/cloud/views/auth/components/FormComponents";
import FeedbackBlock from "pages/SettingsPage/components/FeedbackBlock";
import { Content, SettingsCard } from "pages/SettingsPage/pages/SettingsComponents";

import { useChangeName } from "./hooks";
edmundito marked this conversation as resolved.
Show resolved Hide resolved
import { useValidation } from "./validation";

export const NameSection: React.FC = () => {
const validate = useValidation();
const { formatMessage } = useIntl();
const user = useCurrentUser();
const { changeName, successMessage, errorMessage } = useChangeName();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great use of a hook to keep the component clean.


return (
<SettingsCard title={<FormattedMessage id="settings.account" />}>
<Content>
<Formik
initialValues={{
name: user.name,
}}
onSubmit={changeName}
edmundito marked this conversation as resolved.
Show resolved Hide resolved
validate={validate}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've been using yup to validate the schema. Search the code for an example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

>
{({ isSubmitting, isValid }) => (
<Form>
<RowFieldItem>
<Field name="name">
{({ field, meta }: FieldProps<string>) => (
<LabeledInput
{...field}
label={<FormattedMessage id="settings.accountSettings.name" />}
placeholder={formatMessage({
id: "settings.accountSettings.name.placeholder",
})}
type="text"
error={!!meta.error && meta.touched}
message={meta.touched && meta.error && formatMessage({ id: meta.error })}
/>
)}
</Field>
</RowFieldItem>
<LoadingButton disabled={!isValid} type="submit" isLoading={isSubmitting}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tested and probably we should also check if the form is dirty before we enable the button. Otherwise, the user can press the button to save the same name over and over.

<FormattedMessage id="settings.accountSettings.updateName" />
</LoadingButton>
<FeedbackBlock errorMessage={errorMessage} successMessage={successMessage} />
</Form>
)}
</Formik>
</Content>
</SettingsCard>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useChangeName } from "./useName";
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { FormikHelpers } from "formik/dist/types";
import { useState } from "react";
import { useIntl } from "react-intl";

import { useAuthService } from "packages/cloud/services/auth/AuthService";

import { FormValues } from "../types";

type UseNameHook = () => {
successMessage: string;
errorMessage: string;
changeName: (values: FormValues, { setSubmitting, setFieldValue }: FormikHelpers<FormValues>) => void;
};

export const useChangeName: UseNameHook = () => {
const { updateName } = useAuthService();
const { formatMessage } = useIntl();
const [successMessage, setSuccessMessage] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");

const changeName = async (values: FormValues, { setSubmitting }: FormikHelpers<FormValues>) => {
setSubmitting(true);

setSuccessMessage("");
setErrorMessage("");

try {
await updateName(values.name);

setSuccessMessage(
formatMessage({
id: "settings.accountSettings.updateNameSuccess",
})
);
} catch (err) {
setErrorMessage(
formatMessage({
id: "settings.accountSettings.updateNameError",
}) + JSON.stringify(err)
);
}

setSubmitting(false);
};

return { successMessage, errorMessage, changeName };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NameSection } from "./NameSection";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface FormValues {
name: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FormikErrors } from "formik";
import { useIntl } from "react-intl";

import { FormValues } from "./types";

export const useValidation = () => {
const { formatMessage } = useIntl();
return (values: FormValues): FormikErrors<FormValues> => {
const errors: FormikErrors<FormValues> = {};
if (values.name.length === 0) {
errors.name = formatMessage({ id: "settings.accountSettings.name.empty.error" });
}
return errors;
};
};
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./EmailSection";
export * from "./PasswordSection";
export * from "./NameSection";