diff --git a/client/modules/impower-auth/utils/changeEmail.ts b/client/modules/impower-auth/utils/changeEmail.ts new file mode 100644 index 000000000..24e7d1d82 --- /dev/null +++ b/client/modules/impower-auth/utils/changeEmail.ts @@ -0,0 +1,24 @@ +import { + EmailAuthProvider, + reauthenticateWithCredential, + updateEmail, +} from "firebase/auth"; +import Auth from "../classes/auth"; + +const changeEmail = async ( + password: string, + newEmail: string +): Promise => { + const logInfo = (await import("../../impower-logger/utils/logInfo")).default; + logInfo("Auth", "UPDATING EMAIL"); + const user = Auth.instance.internal.currentUser; + try { + await updateEmail(user, newEmail); + } catch { + const credential = EmailAuthProvider.credential(user.email, password); + await reauthenticateWithCredential(user, credential); + await updateEmail(user, newEmail); + } +}; + +export default changeEmail; diff --git a/client/modules/impower-auth/utils/changePassword.ts b/client/modules/impower-auth/utils/changePassword.ts new file mode 100644 index 000000000..4ab49985d --- /dev/null +++ b/client/modules/impower-auth/utils/changePassword.ts @@ -0,0 +1,24 @@ +import { + EmailAuthProvider, + reauthenticateWithCredential, + updatePassword, +} from "firebase/auth"; +import Auth from "../classes/auth"; + +const changePassword = async ( + oldPassword: string, + newPassword: string +): Promise => { + const logInfo = (await import("../../impower-logger/utils/logInfo")).default; + logInfo("Auth", "UPDATING PASSWORD"); + const user = Auth.instance.internal.currentUser; + try { + await updatePassword(user, newPassword); + } catch { + const credential = EmailAuthProvider.credential(user.email, oldPassword); + await reauthenticateWithCredential(user, credential); + await updatePassword(user, newPassword); + } +}; + +export default changePassword; diff --git a/client/modules/impower-data-store/classes/inspectors/settingsDocumentInspector.ts b/client/modules/impower-data-store/classes/inspectors/settingsDocumentInspector.ts new file mode 100644 index 000000000..e47a136a2 --- /dev/null +++ b/client/modules/impower-data-store/classes/inspectors/settingsDocumentInspector.ts @@ -0,0 +1,35 @@ +import { SettingsDocument } from "../.."; +import { Inspector } from "../../../impower-core"; +import createSettingsDocument from "../../utils/createSettingsDocument"; + +export class SettingsDocumentInspector implements Inspector { + private static _instance: SettingsDocumentInspector; + + public static get instance(): SettingsDocumentInspector { + if (!this._instance) { + this._instance = new SettingsDocumentInspector(); + } + return this._instance; + } + + createData(data?: Partial): SettingsDocument { + return createSettingsDocument(data); + } + + isPropertyDisabled(propertyPath: string, data: SettingsDocument): boolean { + if (propertyPath === "nsfwBlurred") { + return !data.nsfwVisible; + } + return undefined; + } + + getPropertyLabel(propertyPath: string, _data: SettingsDocument): string { + if (propertyPath === "nsfwVisible") { + return "Show NSFW content (I'm over 18)"; + } + if (propertyPath === "nsfwBlurred") { + return "Blur NSFW images"; + } + return undefined; + } +} diff --git a/client/modules/impower-data-store/classes/inspectors/userDocumentInspector.ts b/client/modules/impower-data-store/classes/inspectors/userDocumentInspector.ts index 0f0fd2545..85d1979d0 100644 --- a/client/modules/impower-data-store/classes/inspectors/userDocumentInspector.ts +++ b/client/modules/impower-data-store/classes/inspectors/userDocumentInspector.ts @@ -18,19 +18,6 @@ export class UserDocumentInspector implements Inspector { return createUserDocument(data); } - getPropertyLabel(propertyPath: string, _data: UserDocument): string { - if (propertyPath === "username") { - return "Your username"; - } - if (propertyPath === "bio") { - return "Your bio"; - } - if (propertyPath === "icon") { - return "Your profile image"; - } - return undefined; - } - getPropertyCharacterCountLimit( propertyPath: string, _data: UserDocument diff --git a/client/modules/impower-data-store/types/documents/settingsDocument.ts b/client/modules/impower-data-store/types/documents/settingsDocument.ts index 0c1f82b67..d417688cb 100644 --- a/client/modules/impower-data-store/types/documents/settingsDocument.ts +++ b/client/modules/impower-data-store/types/documents/settingsDocument.ts @@ -3,5 +3,8 @@ import { DataDocument } from "../../../impower-core"; export interface SettingsDocument extends DataDocument<"SettingsDocument"> { _documentType: "SettingsDocument"; emailMarketing: boolean; + emailNotifications: boolean; + appNotifications: boolean; nsfwVisible: boolean; + nsfwBlurred: boolean; } diff --git a/client/modules/impower-data-store/utils/createSettingsDocument.ts b/client/modules/impower-data-store/utils/createSettingsDocument.ts index 788cb4f85..35b8c513f 100644 --- a/client/modules/impower-data-store/utils/createSettingsDocument.ts +++ b/client/modules/impower-data-store/utils/createSettingsDocument.ts @@ -4,8 +4,11 @@ const createSettingsDocument = ( doc?: Partial ): SettingsDocument => ({ _documentType: "SettingsDocument", - emailMarketing: false, nsfwVisible: false, + nsfwBlurred: false, + emailMarketing: false, + emailNotifications: false, + appNotifications: false, ...doc, }); diff --git a/client/modules/impower-route-account/Account.tsx b/client/modules/impower-route-account/Account.tsx new file mode 100644 index 000000000..eb796f883 --- /dev/null +++ b/client/modules/impower-route-account/Account.tsx @@ -0,0 +1,681 @@ +import { useTheme } from "@emotion/react"; +import styled from "@emotion/styled"; +import { FilledInput, IconButton } from "@material-ui/core"; +import Button from "@material-ui/core/Button"; +import Divider from "@material-ui/core/Divider"; +import OutlinedInput from "@material-ui/core/OutlinedInput"; +import Paper from "@material-ui/core/Paper"; +import Typography from "@material-ui/core/Typography"; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import EyeSlashSolidIcon from "../../resources/icons/solid/eye-slash.svg"; +import EyeSolidIcon from "../../resources/icons/solid/eye.svg"; +import { + ConfirmDialogContext, + confirmDialogNavOpen, +} from "../impower-confirm-dialog"; +import { Inspector } from "../impower-core"; +import { + createUserDocument, + UserDocument, + UserDocumentInspector, +} from "../impower-data-store"; +import { SettingsDocumentInspector } from "../impower-data-store/classes/inspectors/settingsDocumentInspector"; +import createSettingsDocument from "../impower-data-store/utils/createSettingsDocument"; +import { useDialogNavigation } from "../impower-dialog"; +import { FontIcon } from "../impower-icon"; +import { TextField } from "../impower-route"; +import InspectorForm from "../impower-route/components/forms/InspectorForm"; +import BooleanInput from "../impower-route/components/inputs/BooleanInput"; +import FileInput from "../impower-route/components/inputs/FileInput"; +import InputHelperText from "../impower-route/components/inputs/InputHelperText"; +import StringDialog from "../impower-route/components/inputs/StringDialog"; +import StringInput from "../impower-route/components/inputs/StringInput"; +import { ToastContext, toastTop } from "../impower-toast"; +import { userOnSetSetting, userOnUpdateSubmission } from "../impower-user"; +import { UserContext } from "../impower-user/contexts/userContext"; + +const changePasswordSuccess = "Password changed!"; +const changeEmailSuccess = "Email changed!"; +const passwordInvalid = "Please enter a valid password."; +const passwordWrong = "The password you entered was incorrect."; +const forgotPasswordQuestion = "Forgot password?"; +const passwordResetEmailSent = + "You should receive an email that explains how to reset your password."; +const signupUsernameAlreadyExists = "Username is already taken."; + +const forgotPasswordConfirmationInfo = { + title: "Reset Your Password?", + content: "We will email you instructions for how to reset your password.", + agreeLabel: "Yes, Reset My Password", + disagreeLabel: "Cancel", +}; + +const StyledContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + align-items: center; +`; + +const StyledPaper = styled(Paper)` + padding: ${(props): string => props.theme.spacing(2, 4, 0, 4)}; + width: 100%; + max-width: ${(props): number => props.theme.breakpoints.values.sm}px; + + ${(props): string => props.theme.breakpoints.down("sm")} { + padding: ${(props): string => props.theme.spacing(1, 2, 0, 2)}; + box-shadow: none; + } +`; + +const StyledHeaderTypography = styled(Typography)` + margin: ${(props): string => props.theme.spacing(2, 0)}; +`; + +const StyledDivider = styled(Divider)` + margin-top: ${(props): string => props.theme.spacing(2)}; +`; + +const StyledLabel = styled.div` + display: flex; + justify-content: flex-start; + padding-right: ${(props): string => props.theme.spacing(3)}; +`; + +const StyledValueArea = styled.div` + flex: 1; + position: relative; + height: 100%; +`; + +const StyledValue = styled.div` + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + height: fit-content; + margin: auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-transform: none; + text-align: right; + color: rgba(0, 0, 0, 0.7); + font-weight: 400; +`; + +const StyledAccountButtonArea = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledAccountButton = styled(Button)` + display: flex; + justify-content: flex-start; + height: 56px; + margin: ${(props): string => props.theme.spacing(1, 0)}; + border-color: rgba(0, 0, 0, 0.27); + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; +`; + +const StyledDialogTextField = styled(TextField)` + & .MuiInputBase-root { + border-radius: 0; + } +`; + +const StyledForgotPasswordArea = styled.div` + display: flex; + justify-content: flex-end; +`; + +const StyledForgotPasswordLink = styled(Button)` + text-transform: none; + white-space: nowrap; +`; + +const StyledKeyboardTrigger = styled.input` + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + opacity: 0; + pointer-events: none; +`; + +const StyledIconButton = styled(IconButton)``; + +const profilePropertyPaths = ["icon", "bio"]; +const settingsPropertyPaths = ["nsfwVisible"]; +const labels = { + username: "Change Username", + email: "Change Email", + password: "Change Password", +}; + +const Profile = React.memo((): JSX.Element | null => { + const [userState, userDispatch] = useContext(UserContext); + const [, confirmDialogDispatch] = useContext(ConfirmDialogContext); + const [, toastDispatch] = useContext(ToastContext); + + const uid = userState?.uid; + const email = userState?.email; + const userDoc = userState?.userDoc; + const settingsDoc = userState?.settings?.account; + + const [newUserDoc, setNewUserDoc] = useState(userDoc); + const [newSettingsDoc, setNewSettingsDoc] = useState(settingsDoc); + const [dialogOpen, setDialogOpen] = useState(); + const [dialogProperty, setDialogProperty] = useState(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newEmail, setNewEmail] = useState(email); + const [dialogError, setDialogError] = useState(); + const [currentPasswordError, setCurrentPasswordError] = useState(); + const [savingBio, setSavingBio] = useState(); + const [currentPasswordReveal, setCurrentPasswordReveal] = useState(false); + const [newPasswordReveal, setNewPasswordReveal] = useState(false); + + const username = newUserDoc?.username; + + const keyboardTriggerRef = useRef(); + + useEffect(() => { + if (email) { + setNewEmail(email); + } + }, [email]); + + useEffect(() => { + if (userDoc) { + setNewUserDoc(userDoc); + } + }, [userDoc]); + + useEffect(() => { + if (settingsDoc) { + setNewSettingsDoc(settingsDoc); + } + }, [settingsDoc]); + + const profileData = useMemo( + () => [newUserDoc || createUserDocument()], + [newUserDoc] + ); + const settingsData = useMemo( + () => [newSettingsDoc || createSettingsDocument()], + [newSettingsDoc] + ); + + const values = useMemo( + () => ({ username, email: newEmail, password: currentPassword }), + [newEmail, currentPassword, username] + ); + + const getProfileInspector = useCallback(() => { + const inspector = UserDocumentInspector.instance as Inspector; + inspector.getPropertyHelperText = ( + propertyPath: string, + _data: UserDocument + ): string => { + if (propertyPath === "bio") { + if (savingBio === undefined) { + return ""; + } + if (savingBio) { + return "Saving..."; + } + return "Saved."; + } + return undefined; + }; + return inspector; + }, [savingBio]); + const getSettingsInspector = useCallback(() => { + return SettingsDocumentInspector.instance; + }, []); + + const handleBrowserNavigation = useCallback( + (currState: Record, prevState?: Record) => { + if (currState?.f !== prevState?.f) { + setDialogProperty(currState?.f); + setDialogOpen(Boolean(currState?.f)); + } + }, + [] + ); + const [openFieldDialog, closeFieldDialog] = useDialogNavigation( + "f", + handleBrowserNavigation + ); + + const handleClickChangeUsername = useCallback(() => { + if (keyboardTriggerRef.current) { + keyboardTriggerRef.current.focus(); + } + setDialogError(undefined); + setCurrentPasswordError(undefined); + setDialogProperty("username"); + openFieldDialog("username"); + setDialogOpen(true); + }, [openFieldDialog]); + const handleClickChangeEmail = useCallback(() => { + if (keyboardTriggerRef.current) { + keyboardTriggerRef.current.focus(); + } + setDialogError(undefined); + setCurrentPasswordError(undefined); + setCurrentPassword(""); + setDialogProperty("email"); + openFieldDialog("email"); + setDialogOpen(true); + }, [openFieldDialog]); + const handleClickChangePassword = useCallback(async () => { + if (keyboardTriggerRef.current) { + keyboardTriggerRef.current.focus(); + } + setDialogError(undefined); + setCurrentPasswordError(undefined); + setCurrentPassword(""); + setNewPassword(""); + setDialogProperty("password"); + openFieldDialog("password"); + setDialogOpen(true); + }, [openFieldDialog]); + const handleCloseDialog = useCallback(() => { + closeFieldDialog(); + setDialogOpen(false); + }, [closeFieldDialog]); + const handleClickForgotPassword = useCallback(async () => { + const onYes = async (): Promise => { + const forgotPassword = ( + await import("../impower-auth/utils/forgotPassword") + ).default; + try { + await forgotPassword(email); + toastDispatch(toastTop(passwordResetEmailSent, "info")); + } catch (error) { + const logError = (await import("../impower-logger/utils/logError")) + .default; + switch (error.code) { + default: + toastDispatch(toastTop(error.message, "error")); + logError("Auth", error); + } + } + }; + confirmDialogDispatch( + confirmDialogNavOpen( + forgotPasswordConfirmationInfo.title, + forgotPasswordConfirmationInfo.content, + forgotPasswordConfirmationInfo.agreeLabel, + onYes, + forgotPasswordConfirmationInfo.disagreeLabel + ) + ); + }, [confirmDialogDispatch, email, toastDispatch]); + const handleChangeCurrentPassword = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setCurrentPassword(newValue); + }, + [] + ); + const handleChangeNewPassword = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setNewPassword(newValue); + }, + [] + ); + const handleSubmit = useCallback( + async (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (dialogProperty === "username") { + const updates = { [dialogProperty]: newValue }; + const updatedDoc = { ...newUserDoc, ...updates }; + const DataStoreRead = ( + await import("../impower-data-store/classes/dataStoreRead") + ).default; + const snapshot = await new DataStoreRead( + "handles", + newValue.toLowerCase() + ).get(); + if (snapshot.exists()) { + setDialogError(signupUsernameAlreadyExists); + return false; + } + setNewUserDoc(updatedDoc); + await new Promise((resolve) => + userDispatch( + userOnUpdateSubmission(resolve, updatedDoc, "users", uid) + ) + ); + } + if (dialogProperty === "email") { + setNewEmail(newValue); + const changeEmail = (await import("../impower-auth/utils/changeEmail")) + .default; + try { + await changeEmail(currentPassword, newEmail); + toastDispatch(toastTop(changeEmailSuccess, "success")); + } catch (error) { + const logError = (await import("../impower-logger/utils/logError")) + .default; + switch (error.code) { + case "auth/invalid-password": + setCurrentPasswordError(passwordInvalid); + return false; + case "auth/wrong-password": + case "auth/internal-error": + setCurrentPasswordError(passwordWrong); + return false; + default: + toastDispatch(toastTop(error.message, "error")); + logError("Auth", error); + return false; + } + } + } + if (dialogProperty === "password") { + const changePassword = ( + await import("../impower-auth/utils/changePassword") + ).default; + try { + await changePassword(currentPassword, newPassword); + toastDispatch(toastTop(changePasswordSuccess, "success")); + } catch (error) { + const logError = (await import("../impower-logger/utils/logError")) + .default; + switch (error.code) { + case "auth/invalid-password": + setDialogError(passwordInvalid); + return false; + case "auth/wrong-password": + case "auth/internal-error": + setDialogError(passwordWrong); + return false; + default: + toastDispatch(toastTop(error.message, "error")); + logError("Auth", error); + return false; + } + } + } + return true; + }, + [ + dialogProperty, + newUserDoc, + userDispatch, + uid, + currentPassword, + newEmail, + toastDispatch, + newPassword, + ] + ); + const handleProfilePropertyBlur = useCallback( + async (propertyPath: string, value: unknown) => { + if (JSON.stringify(newUserDoc[propertyPath]) === JSON.stringify(value)) { + return; + } + const updates = { [propertyPath]: value }; + const updatedDoc = { ...newUserDoc, ...updates }; + setNewUserDoc(updatedDoc); + if (propertyPath === "bio") { + setSavingBio(true); + } + await new Promise((resolve) => + userDispatch(userOnUpdateSubmission(resolve, updatedDoc, "users", uid)) + ); + if (propertyPath === "bio") { + setSavingBio(false); + } + }, + [newUserDoc, uid, userDispatch] + ); + const handleSettingsPropertyChange = useCallback( + async (propertyPath: string, value: unknown) => { + if (JSON.stringify(newUserDoc[propertyPath]) === JSON.stringify(value)) { + return; + } + const updates = { [propertyPath]: value }; + const updatedDoc = { ...newSettingsDoc, ...updates }; + setNewSettingsDoc(updatedDoc); + await new Promise((resolve) => + userDispatch(userOnSetSetting(resolve, updatedDoc, "account")) + ); + }, + [newSettingsDoc, newUserDoc, userDispatch] + ); + const handleRevealCurrentPassword = useCallback((): void => { + setCurrentPasswordReveal(!currentPasswordReveal); + }, [currentPasswordReveal]); + const handleRevealNewPassword = useCallback((): void => { + setNewPasswordReveal(!newPasswordReveal); + }, [newPasswordReveal]); + + const requiresRecentAuth = + dialogProperty === "password" || dialogProperty === "email"; + + const theme = useTheme(); + + const CurrentPasswordDialogTextFieldInputProps = useMemo( + () => ({ + style: { + backgroundColor: "transparent", + }, + endAdornment: ( + + + {currentPasswordReveal ? : } + + + ), + }), + [handleRevealCurrentPassword, currentPasswordReveal, theme.palette.grey] + ); + const NewPasswordDialogTextFieldInputProps = useMemo( + () => ({ + style: { + backgroundColor: "transparent", + }, + endAdornment: ( + + + {newPasswordReveal ? : } + + + ), + }), + [handleRevealNewPassword, newPasswordReveal, theme.palette.grey] + ); + + const renderHelperText = (props: { + errorText: string; + helperText: React.ReactNode; + counterText: string; + }): React.ReactNode => { + const { errorText, helperText, counterText } = props; + if (!errorText && !helperText && !counterText) { + return undefined; + } + return ( + + ); + }; + return ( + <> +