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.
+
+
+
+
+
+
+
+ Email address |
+ |
+
+
+
+ {user.userEmails.nodes.map((email, i) => (
+ 1}
+ />
+ ))}
+
+
+
+
+
+ {!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 }) => (
+
+ )}
+
+ );
+}
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 }) => (
+
+ )}
+
+ );
+}
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 }) => (
+
+ )}
+
+
+ );
+};
+
+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 || "";