diff --git a/packages/dashboard-v2/gatsby-browser.js b/packages/dashboard-v2/gatsby-browser.js
index a71e49c3..a39bdb48 100644
--- a/packages/dashboard-v2/gatsby-browser.js
+++ b/packages/dashboard-v2/gatsby-browser.js
@@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold
import "@fontsource/source-sans-pro/400.css"; // normal
import "@fontsource/source-sans-pro/600.css"; // semibold
import "./src/styles/global.css";
+import { MODAL_ROOT_ID } from "./src/components/Modal";
export function wrapPageElement({ element, props }) {
const Layout = element.type.Layout ?? React.Fragment;
- return {element} ;
+ return (
+
+ {element}
+
+
+ );
}
diff --git a/packages/dashboard-v2/gatsby-ssr.js b/packages/dashboard-v2/gatsby-ssr.js
index a71e49c3..a39bdb48 100644
--- a/packages/dashboard-v2/gatsby-ssr.js
+++ b/packages/dashboard-v2/gatsby-ssr.js
@@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold
import "@fontsource/source-sans-pro/400.css"; // normal
import "@fontsource/source-sans-pro/600.css"; // semibold
import "./src/styles/global.css";
+import { MODAL_ROOT_ID } from "./src/components/Modal";
export function wrapPageElement({ element, props }) {
const Layout = element.type.Layout ?? React.Fragment;
- return {element} ;
+ return (
+
+ {element}
+
+
+ );
}
diff --git a/packages/dashboard-v2/src/components/Alert/Alert.js b/packages/dashboard-v2/src/components/Alert/Alert.js
new file mode 100644
index 00000000..0e6ab345
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Alert/Alert.js
@@ -0,0 +1,9 @@
+import styled from "styled-components";
+import cn from "classnames";
+
+export const Alert = styled.div.attrs(({ $variant }) => ({
+ className: cn("px-3 py-2 sm:px-6 sm:py-4 rounded border", {
+ "bg-red-100 border-red-200 text-error": $variant === "error",
+ "bg-green-100 border-green-200 text-palette-400": $variant === "success",
+ }),
+}))``;
diff --git a/packages/dashboard-v2/src/components/Alert/index.js b/packages/dashboard-v2/src/components/Alert/index.js
new file mode 100644
index 00000000..b8e17a03
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Alert/index.js
@@ -0,0 +1 @@
+export * from "./Alert";
diff --git a/packages/dashboard-v2/src/components/Form/TextField.js b/packages/dashboard-v2/src/components/Form/TextField.js
index 7eb811a8..6ae35021 100644
--- a/packages/dashboard-v2/src/components/Form/TextField.js
+++ b/packages/dashboard-v2/src/components/Form/TextField.js
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import cn from "classnames";
import { Field } from "formik";
-export const TextField = ({ id, label, name, error, touched, ...props }) => {
+export const TextField = ({ id, label, name, error, touched, className, ...props }) => {
return (
{label && (
@@ -13,7 +13,7 @@ export const TextField = ({ id, label, name, error, touched, ...props }) => {
(
+
+
+
+
+
+);
+
+Modal.propTypes = {
+ /**
+ * Modal's body.
+ */
+ children: PropTypes.node.isRequired,
+ /**
+ * Handler function to be called when user clicks on the "X" icon,
+ * or outside of the modal.
+ */
+ onClose: PropTypes.func.isRequired,
+ /**
+ * Additional CSS classes to be applied to modal's body.
+ */
+ className: PropTypes.string,
+};
diff --git a/packages/dashboard-v2/src/components/Modal/ModalPortal.js b/packages/dashboard-v2/src/components/Modal/ModalPortal.js
new file mode 100644
index 00000000..ef18e612
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Modal/ModalPortal.js
@@ -0,0 +1,16 @@
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+export const MODAL_ROOT_ID = "__modal-root";
+
+export const ModalPortal = ({ children }) => {
+ const ref = useRef();
+ const [isClientSide, setIsClientSide] = useState(false);
+
+ useEffect(() => {
+ ref.current = document.querySelector(MODAL_ROOT_ID) || document.body;
+ setIsClientSide(true);
+ }, []);
+
+ return isClientSide ? createPortal(children, ref.current) : null;
+};
diff --git a/packages/dashboard-v2/src/components/Modal/Overlay.js b/packages/dashboard-v2/src/components/Modal/Overlay.js
new file mode 100644
index 00000000..7f1cbb35
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Modal/Overlay.js
@@ -0,0 +1,42 @@
+import { useRef } from "react";
+import { useLockBodyScroll } from "react-use";
+import PropTypes from "prop-types";
+
+export const Overlay = ({ children, onClick }) => {
+ const overlayRef = useRef(null);
+
+ useLockBodyScroll(true);
+
+ const handleClick = (event) => {
+ if (event.target !== overlayRef.current) {
+ return;
+ }
+
+ event.nativeEvent.stopImmediatePropagation();
+
+ onClick?.(event);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+Overlay.propTypes = {
+ /**
+ * Overlay's body.
+ */
+ children: PropTypes.node.isRequired,
+ /**
+ * Handler function to be called when user clicks on the overlay
+ * (but not the overlay's content).
+ */
+ onClick: PropTypes.func,
+};
diff --git a/packages/dashboard-v2/src/components/Modal/index.js b/packages/dashboard-v2/src/components/Modal/index.js
new file mode 100644
index 00000000..28d34710
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Modal/index.js
@@ -0,0 +1 @@
+export * from "./ModalPortal";
diff --git a/packages/dashboard-v2/src/components/forms/AccountRemovalForm.js b/packages/dashboard-v2/src/components/forms/AccountRemovalForm.js
new file mode 100644
index 00000000..bdd7196e
--- /dev/null
+++ b/packages/dashboard-v2/src/components/forms/AccountRemovalForm.js
@@ -0,0 +1,78 @@
+import * as Yup from "yup";
+import { useState } from "react";
+import PropTypes from "prop-types";
+import { Formik, Form } from "formik";
+
+import { Button } from "../Button";
+import { TextField } from "../Form/TextField";
+import accountsService from "../../services/accountsService";
+
+const accountRemovalSchema = Yup.object().shape({
+ confirm: Yup.string().oneOf(["delete"], `Type "delete" to confirm`),
+});
+
+export const AccountRemovalForm = ({ abort, onSuccess }) => {
+ const [error, setError] = useState(false);
+
+ return (
+ {
+ try {
+ setError(false);
+ await accountsService.delete("user");
+ onSuccess();
+ } catch {
+ setError(true);
+ }
+ }}
+ >
+ {({ errors, touched, isValid, dirty }) => (
+
+ )}
+
+ );
+};
+
+AccountRemovalForm.propTypes = {
+ abort: PropTypes.func.isRequired,
+ onSuccess: PropTypes.func.isRequired,
+};
diff --git a/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js
new file mode 100644
index 00000000..2c382c1b
--- /dev/null
+++ b/packages/dashboard-v2/src/components/forms/AccountSettingsForm.js
@@ -0,0 +1,111 @@
+import * as Yup from "yup";
+import PropTypes from "prop-types";
+import { Formik, Form } from "formik";
+
+import { Button } from "../Button";
+import { TextField } from "../Form/TextField";
+import accountsService from "../../services/accountsService";
+
+const isPopulated = (value) => value?.length > 0;
+
+const emailUpdateSchema = Yup.object().shape({
+ email: Yup.string().email("Please provide a valid email address"),
+ confirmEmail: Yup.string()
+ .oneOf([Yup.ref("email"), null], "Emails must match")
+ .when("email", {
+ is: isPopulated,
+ then: (schema) => schema.required("Please confirm new email address"),
+ }),
+ password: Yup.string().min(1, "Password can't be blank"),
+ confirmPassword: Yup.string()
+ .oneOf([Yup.ref("password"), null], "Passwords must match")
+ .when("password", {
+ is: isPopulated,
+ then: (schema) => schema.required("Please confirm new password"),
+ }),
+});
+
+export const AccountSettingsForm = ({ user, onSuccess, onFailure }) => {
+ return (
+ {
+ try {
+ const user = await accountsService
+ .put("user", {
+ json: {
+ email: email || undefined,
+ password: password || undefined,
+ },
+ })
+ .json();
+
+ resetForm();
+ await onSuccess(user);
+ } catch {
+ onFailure();
+ }
+ }}
+ >
+ {({ errors, touched, isValid, dirty }) => (
+
+ )}
+
+ );
+};
+
+AccountSettingsForm.propTypes = {
+ onSuccess: PropTypes.func.isRequired,
+ onFailure: PropTypes.func.isRequired,
+};
diff --git a/packages/dashboard-v2/src/pages/settings/index.js b/packages/dashboard-v2/src/pages/settings/index.js
index 459cf8c0..358a4b28 100644
--- a/packages/dashboard-v2/src/pages/settings/index.js
+++ b/packages/dashboard-v2/src/pages/settings/index.js
@@ -1,19 +1,42 @@
-import * as React from "react";
-import { useMedia } from "react-use";
-import styled from "styled-components";
+import { useCallback, useState } from "react";
+import { navigate } from "gatsby";
-import theme from "../../lib/theme";
+import { useUser } from "../../contexts/user";
import UserSettingsLayout from "../../layouts/UserSettingsLayout";
-import { TextInputBasic } from "../../components/TextInputBasic/TextInputBasic";
-import { Button } from "../../components/Button";
-import { AvatarUploader } from "../../components/AvatarUploader";
+import { AccountSettingsForm } from "../../components/forms/AccountSettingsForm";
+import { Modal } from "../../components/Modal/Modal";
+import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm";
+import { Alert } from "../../components/Alert";
-const FormGroup = styled.div.attrs({
- className: "grid sm:grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-end",
-})``;
+const State = {
+ Pure: "PURE",
+ Success: "SUCCESS",
+ Failure: "FAILURE",
+};
const AccountPage = () => {
- const isLargeScreen = useMedia(`(min-width: ${theme.screens.xl})`);
+ const { user, mutate: reloadUser } = useUser();
+ const [state, setState] = useState(State.Pure);
+ const [removalInitiated, setRemovalInitiated] = useState(false);
+
+ const prompt = () => setRemovalInitiated(true);
+ const abort = () => setRemovalInitiated(false);
+
+ const onSettingsUpdated = useCallback(
+ async (updatedState) => {
+ try {
+ // Update state locally and request revalidation.
+ await reloadUser(updatedState);
+ } finally {
+ // If revalidation fails, we can't really do much. Request
+ // will be auto-retried by SWR, so we'll just show a message
+ // about the update request being successful.
+ setState(State.Success);
+ }
+ },
+ [reloadUser]
+ );
+
return (
<>
@@ -26,34 +49,12 @@ const AccountPage = () => {
- {!isLargeScreen && (
-
- )}
-
-
-
- Update
-
-
-
-
-
- Update
-
-
-
-
-
- Update
-
-
- The password must be at least 6 characters long. Significantly different from the email and old
- password.
-
-
+ {state === State.Failure && (
+ There was an error processing your request. Please try again later.
+ )}
+ {state === State.Success && Changes saved successfully. }
+ setState(State.Failure)} />
@@ -61,16 +62,18 @@ const AccountPage = () => {
This will completely delete your account. This process can't be undone.
window.confirm("TODO: confirmation modal")}
+ onClick={prompt}
className="text-error underline decoration-1 hover:decoration-dashed"
>
Delete account
-
+ {removalInitiated && (
+
+ navigate("/auth/login")} />
+
+ )}
>
);