diff --git a/apps/homepage/src/components/SharedLayoutLoggedIn.tsx b/apps/homepage/src/components/SharedLayoutLoggedIn.tsx index ac30bcb..78cbb14 100644 --- a/apps/homepage/src/components/SharedLayoutLoggedIn.tsx +++ b/apps/homepage/src/components/SharedLayoutLoggedIn.tsx @@ -57,7 +57,12 @@ export function SharedLayoutLoggedIn( - } name="Settings"> + } + name="Settings" + > diff --git a/apps/homepage/src/graphql/Model/User/ChangePassword.graphql b/apps/homepage/src/graphql/Model/User/ChangePassword.graphql new file mode 100644 index 0000000..2d9a6a7 --- /dev/null +++ b/apps/homepage/src/graphql/Model/User/ChangePassword.graphql @@ -0,0 +1,7 @@ +mutation ChangePassword($oldPassword: String!, $newPassword: String!) { + changePassword( + input: { oldPassword: $oldPassword, newPassword: $newPassword } + ) { + success + } +} diff --git a/apps/homepage/src/graphql/Model/User/ProfileSettingsForm_User.graphql b/apps/homepage/src/graphql/Model/User/ProfileSettingsForm_User.graphql new file mode 100644 index 0000000..26c56c2 --- /dev/null +++ b/apps/homepage/src/graphql/Model/User/ProfileSettingsForm_User.graphql @@ -0,0 +1,6 @@ +fragment ProfileSettingsForm_User on User { + id + name + username + avatarUrl +} diff --git a/apps/homepage/src/graphql/Model/User/UpdateUser.graphql b/apps/homepage/src/graphql/Model/User/UpdateUser.graphql new file mode 100644 index 0000000..2f70178 --- /dev/null +++ b/apps/homepage/src/graphql/Model/User/UpdateUser.graphql @@ -0,0 +1,10 @@ +mutation UpdateUser($id: UUID!, $patch: UserPatch!) { + updateUser(input: { id: $id, patch: $patch }) { + clientMutationId + user { + id + name + username + } + } +} diff --git a/apps/homepage/src/graphql/Model/UserEmail/AddEmail.graphql b/apps/homepage/src/graphql/Model/UserEmail/AddEmail.graphql new file mode 100644 index 0000000..f38d94c --- /dev/null +++ b/apps/homepage/src/graphql/Model/UserEmail/AddEmail.graphql @@ -0,0 +1,15 @@ +#import "./EmailsForm_UserEmail.graphql" + +mutation AddEmail($email: String!) { + createUserEmail(input: { userEmail: { email: $email } }) { + user { + id + userEmails(first: 50) { + nodes { + id + ...EmailsForm_UserEmail + } + } + } + } +} diff --git a/apps/homepage/src/graphql/Model/UserEmail/DeleteEmail.graphql b/apps/homepage/src/graphql/Model/UserEmail/DeleteEmail.graphql new file mode 100644 index 0000000..452053c --- /dev/null +++ b/apps/homepage/src/graphql/Model/UserEmail/DeleteEmail.graphql @@ -0,0 +1,15 @@ +#import "./EmailsForm_UserEmail.graphql" + +mutation DeleteEmail($emailId: UUID!) { + deleteUserEmail(input: { id: $emailId }) { + user { + id + userEmails(first: 50) { + nodes { + id + ...EmailsForm_UserEmail + } + } + } + } +} diff --git a/apps/homepage/src/graphql/Model/UserEmail/EmailsForm_User.graphql b/apps/homepage/src/graphql/Model/UserEmail/EmailsForm_User.graphql new file mode 100644 index 0000000..d7263f0 --- /dev/null +++ b/apps/homepage/src/graphql/Model/UserEmail/EmailsForm_User.graphql @@ -0,0 +1,13 @@ +#import "./EmailsForm_UserEmail.graphql" + +fragment EmailsForm_User on User { + id + userEmails(first: 50) { + nodes { + ...EmailsForm_UserEmail + id + email + isVerified + } + } +} diff --git a/apps/homepage/src/graphql/Model/UserEmail/EmailsForm_UserEmail.graphql b/apps/homepage/src/graphql/Model/UserEmail/EmailsForm_UserEmail.graphql new file mode 100644 index 0000000..189d67c --- /dev/null +++ b/apps/homepage/src/graphql/Model/UserEmail/EmailsForm_UserEmail.graphql @@ -0,0 +1,7 @@ +fragment EmailsForm_UserEmail on UserEmail { + id + email + isVerified + isPrimary + createdAt +} diff --git a/apps/homepage/src/graphql/Model/UserEmail/MakeEmailPrimary.graphql b/apps/homepage/src/graphql/Model/UserEmail/MakeEmailPrimary.graphql new file mode 100644 index 0000000..aca1aee --- /dev/null +++ b/apps/homepage/src/graphql/Model/UserEmail/MakeEmailPrimary.graphql @@ -0,0 +1,13 @@ +mutation MakeEmailPrimary($emailId: UUID!) { + makeEmailPrimary(input: { emailId: $emailId }) { + user { + id + userEmails(first: 50) { + nodes { + id + isPrimary + } + } + } + } +} diff --git a/apps/homepage/src/graphql/Pages/Settings/SettingsEmailsPage.graphql b/apps/homepage/src/graphql/Pages/Settings/SettingsEmailsPage.graphql new file mode 100644 index 0000000..849aed4 --- /dev/null +++ b/apps/homepage/src/graphql/Pages/Settings/SettingsEmailsPage.graphql @@ -0,0 +1,10 @@ +#import "./EmailsForm_User.graphql" + +query SettingsEmailsPage { + ...SharedLayout_Query + currentUser { + id + isVerified + ...EmailsForm_User + } +} diff --git a/apps/homepage/src/graphql/Pages/Settings/SettingsPasswordPage.graphql b/apps/homepage/src/graphql/Pages/Settings/SettingsPasswordPage.graphql new file mode 100644 index 0000000..06051ba --- /dev/null +++ b/apps/homepage/src/graphql/Pages/Settings/SettingsPasswordPage.graphql @@ -0,0 +1,12 @@ +query SettingsPasswordPage { + currentUser { + id + hasPassword + userEmails(first: 1, condition: { isPrimary: true }) { + nodes { + id + email + } + } + } +} diff --git a/apps/homepage/src/graphql/Pages/Settings/SettingsProfilePage.graphql b/apps/homepage/src/graphql/Pages/Settings/SettingsProfilePage.graphql new file mode 100644 index 0000000..e3441a7 --- /dev/null +++ b/apps/homepage/src/graphql/Pages/Settings/SettingsProfilePage.graphql @@ -0,0 +1,9 @@ +#import "./ProfileSettingsForm_User.graphql" + +query SettingsProfilePage { + ...SharedLayout_Query + currentUser { + id + ...ProfileSettingsForm_User + } +} diff --git a/apps/homepage/src/pages/settings/delete.tsx b/apps/homepage/src/pages/settings/delete.tsx new file mode 100644 index 0000000..5453c1f --- /dev/null +++ b/apps/homepage/src/pages/settings/delete.tsx @@ -0,0 +1,22 @@ +import { SharedLayoutLoggedIn } from "@/components/SharedLayoutLoggedIn"; +import { Link, Text } from "@chakra-ui/react"; +import { supportEmail } from "@repo/config"; +import { useSharedQuery } from "@repo/graphql"; +import { NextPage } from "next"; +import React from "react"; + +const Settings_Accounts: NextPage = () => { + const query = useSharedQuery(); + return ( + + + Automatic deletion is not supported yet. If you want to proceed, please + send an email to{" "} + {supportEmail}. Sorry for + the inconvenience. + + + ); +}; + +export default Settings_Accounts; diff --git a/apps/homepage/src/pages/settings/emails.tsx b/apps/homepage/src/pages/settings/emails.tsx new file mode 100644 index 0000000..fa2c95b --- /dev/null +++ b/apps/homepage/src/pages/settings/emails.tsx @@ -0,0 +1,319 @@ +import { Redirect } from "@/components/Redirect"; +import { SharedLayoutLoggedIn } from "@/components/SharedLayoutLoggedIn"; +import { ApolloError } from "@apollo/client"; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Divider, + HStack, + Heading, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + VStack, +} from "@chakra-ui/react"; +import { + EmailsForm_UserEmailFragment, + useAddEmailMutation, + useDeleteEmailMutation, + useMakeEmailPrimaryMutation, + useResendEmailVerificationMutation, + useSettingsEmailsPageQuery, +} from "@repo/graphql"; +import { extractError } from "@repo/lib"; +import { ErrorAlert, PopConfirm } from "@repo/ui"; +import { Form, Formik } from "formik"; +import { InputControl, SubmitButton } from "formik-chakra-ui"; +import { NextPage } from "next"; +import React, { useCallback, useState } from "react"; +import { toast } from "react-toastify"; +import * as Yup from "yup"; + +function Email({ + email, + hasOtherEmails, +}: { + email: EmailsForm_UserEmailFragment; + hasOtherEmails: boolean; +}) { + const canDelete = !email.isPrimary && hasOtherEmails; + const [deleteEmail] = useDeleteEmailMutation(); + const [resendEmailVerification] = useResendEmailVerificationMutation(); + const [makeEmailPrimary] = useMakeEmailPrimaryMutation(); + + const actions = [ + email.isPrimary && ( + + Primary + + ), + canDelete && ( + { + deleteEmail({ + variables: { emailId: email.id }, + onCompleted: () => { + toast.success("Email deleted!"); + }, + }); + }} + okText="Yes" + cancelText="No" + key="remove" + > + + + ), + !email.isVerified && ( + + ), + email.isVerified && !email.isPrimary && ( + + ), + ].filter((x) => !!x); + + return ( + + + + + + {email.email}{" "} + + {email.isVerified ? ( + "✅" + ) : ( + + (unverified) + + )} + + + + + Added {new Date(Date.parse(email.createdAt)).toLocaleString()} + + + + + + {actions.map((action, i) => [ + action, + i !== actions.length - 1 && ( + + ), + ])} + + + + ); +} + +const Settings_Emails: NextPage = () => { + const [showAddEmailForm, setShowAddEmailForm] = useState(false); + const [formError, setFormError] = useState(null); + const query = useSettingsEmailsPageQuery(); + const { data, loading, error } = query; + const user = data && data.currentUser; + + return ( + + {error && !loading ? ( + + ) : !user && !loading ? ( + + ) : !user ? ( + "Loading" + ) : ( + + Email Addresses + {user.isVerified ? null : ( + + + + + No verified emails + + You do not have any verified email addresses, this will make + account recovery impossible and may limit your available + functionality within this application. Please complete email + verification. + + + + + )} + + Account notices will be sent your primary email address.{" "} + Additional email addresses may be added to help with account + recovery (or to change your primary email), but they cannot be used + until verified. + + + + + + + + + + + + + {user.userEmails.nodes.map((email, i) => ( + 1} + /> + ))} + +
Email address
+ + + + {!showAddEmailForm ? ( + + ) : ( + setShowAddEmailForm(false)} + error={formError} + setError={setFormError} + /> + )} + + )} +
+ ); +}; + +export default Settings_Emails; + +const validationSchema = Yup.object({ + email: Yup.string().required("Please enter an email address"), +}); +type FormInputs = Yup.InferType; + +interface AddEmailFormProps { + onComplete: () => void; + error: Error | ApolloError | null; + setError: (error: Error | ApolloError | null) => void; +} + +function AddEmailForm({ error, setError, onComplete }: AddEmailFormProps) { + const [addEmail] = useAddEmailMutation(); + + const onSubmit = useCallback( + async (values: FormInputs) => { + setError(null); + try { + await addEmail({ variables: { email: values.email } }); + onComplete(); + setError(null); + } catch (e: any) { + setError(e); + } + }, + [addEmail, onComplete, setError], + ); + + return ( + + {({ handleSubmit }) => ( +
+ + + + {error ? ( + + + + Error: Failed to add email + + {extractError(error).message} + + + + ) : null} + + + Add email + + +
+ )} +
+ ); +} diff --git a/apps/homepage/src/pages/settings/profile.tsx b/apps/homepage/src/pages/settings/profile.tsx new file mode 100644 index 0000000..1056a5b --- /dev/null +++ b/apps/homepage/src/pages/settings/profile.tsx @@ -0,0 +1,175 @@ +import { Redirect } from "@/components/Redirect"; +import { SharedLayoutLoggedIn } from "@/components/SharedLayoutLoggedIn"; +import { ApolloError } from "@apollo/client"; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Heading, + VStack, +} from "@chakra-ui/react"; +import { + ProfileSettingsForm_UserFragment, + useSettingsProfilePageQuery, + useUpdateUserMutation, +} from "@repo/graphql"; +import { extractError, getCodeFromError } from "@repo/lib"; +import { ErrorAlert } from "@repo/ui"; +import { Form, Formik, FormikHelpers } from "formik"; +import { InputControl, SubmitButton } from "formik-chakra-ui"; +import { NextPage } from "next"; +import React, { useCallback, useState } from "react"; +import * as Yup from "yup"; + +const Settings_Profile: NextPage = () => { + const [formError, setFormError] = useState(null); + const query = useSettingsProfilePageQuery(); + const { data, loading, error } = query; + return ( + + {data && data.currentUser ? ( + + ) : loading ? ( + "Loading..." + ) : error ? ( + + ) : ( + + )} + + ); +}; + +export default Settings_Profile; + +const validationSchema = Yup.object({ + name: Yup.string().required("Please enter your name"), + username: Yup.string() + .min(2, "Username must be at least 2 characters long.") + .matches(/^([a-zA-Z]|$)/, "Username must start with a letter.") + .matches( + /^([^_]|_[^_]|_$)*$/, + "Username must not contain two underscores next to each other.", + ) + .matches( + /^[a-zA-Z0-9_]*$/, + "Username must contain only alphanumeric characters and underscores.", + ) + .required("Please enter a username"), +}); +type FormInputs = Yup.InferType; + +interface ProfileSettingsFormProps { + user: ProfileSettingsForm_UserFragment; + error: Error | ApolloError | null; + setError: (error: Error | ApolloError | null) => void; +} + +function ProfileSettingsForm({ + user, + error, + setError, +}: ProfileSettingsFormProps) { + const [updateUser, { data }] = useUpdateUserMutation(); + const success = !!data?.updateUser; + + const onSubmit = useCallback( + async (values: FormInputs, formikHelpers: FormikHelpers) => { + setError(null); + try { + await updateUser({ + variables: { + id: user.id, + patch: { + username: values.username, + name: values.name, + }, + }, + }); + setError(null); + } catch (e: any) { + const errcode = getCodeFromError(e); + if (errcode === "23505" || errcode === "NUNIQ") { + formikHelpers.setFieldError( + "username", + "This username is already in use, please pick a different name", + ); + } else { + setError(e); + } + } + }, + [setError, updateUser, user.id], + ); + + return ( + + {({ handleSubmit }) => ( +
+ + Profile Settings + + + + + {error ? ( + + + + + Error: Failed to update profile + + + {extractError(error).message} + + + + ) : null} + + {success ? ( + + + + Profile updated + + + ) : null} + + + Update Profile + + +
+ )} +
+ ); +} diff --git a/apps/homepage/src/pages/settings/security.tsx b/apps/homepage/src/pages/settings/security.tsx new file mode 100644 index 0000000..586b192 --- /dev/null +++ b/apps/homepage/src/pages/settings/security.tsx @@ -0,0 +1,142 @@ +import { SharedLayoutLoggedIn } from "@/components/SharedLayoutLoggedIn"; +import { WrappedPasswordStrength } from "@/components/WrappedPasswordStrength"; +import { ApolloError } from "@apollo/client"; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Heading, + VStack, +} from "@chakra-ui/react"; +import { useChangePasswordMutation, useSharedQuery } from "@repo/graphql"; +import { extractError, getCodeFromError } from "@repo/lib"; +import { Form, Formik, FormikHelpers } from "formik"; +import { InputControl, SubmitButton } from "formik-chakra-ui"; +import { NextPage } from "next"; +import React, { useCallback, useState } from "react"; +import * as Yup from "yup"; + +const validationSchema = Yup.object({ + oldPassword: Yup.string().required("Please enter your current password"), + password: Yup.string().required("Please enter your new password"), +}); +type FormInputs = Yup.InferType; + +const Settings_Security: NextPage = () => { + const [error, setError] = useState(null); + const query = useSharedQuery(); + + const [changePassword, { data }] = useChangePasswordMutation(); + const success = !!data?.changePassword?.success; + + const onSubmit = useCallback( + async (values: FormInputs, formikHelpers: FormikHelpers) => { + setError(null); + try { + await changePassword({ + variables: { + oldPassword: values.oldPassword, + newPassword: values.password, + }, + }); + formikHelpers.resetForm(); + setError(null); + } catch (e: any) { + const code = getCodeFromError(e); + if (code === "WEAKP") { + formikHelpers.setFieldError( + "password", + "This password is too weak, please try a stronger password.", + ); + } else if (code === "CREDS") { + formikHelpers.setFieldError("oldPassword", "Incorrect old password"); + } else { + setError(e); + } + } + }, + [changePassword], + ); + + return ( + + + {({ handleSubmit }) => ( +
+ + Change Password + + + + + + + {error ? ( + + + + + Error: Failed to change password + + + {extractError(error).message} + + + + ) : null} + + {success ? ( + + + + Password changed! + + + ) : null} + + + Change Password + + +
+ )} +
+
+ ); +}; + +export default Settings_Security; diff --git a/backend/config/src/index.ts b/backend/config/src/index.ts index 8b2b04a..ca06f39 100644 --- a/backend/config/src/index.ts +++ b/backend/config/src/index.ts @@ -1,13 +1,10 @@ // @ts-ignore const packageJson = require("../../../package.json"); -// TODO: customise this with your own settings! - -export const fromEmail = - '"TheOpenPresenter" '; +export const fromEmail = '"TheOpenPresenter" '; +export const supportEmail = "support@theopenpresenter.com"; export const awsRegion = "eu-west-2"; export const projectName = packageJson.projectName.replace(/[-_]/g, " "); -export const companyName = projectName; // For copyright ownership +export const companyName = projectName; export const emailLegalText = - // Envvar here so we can override on the demo website process.env.LEGAL_TEXT || "";