From fb03079d4d0d61a6f023d9f3b3b7fb0c6b1630da Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Wed, 9 Aug 2023 11:24:12 -0400 Subject: [PATCH] feat: add user profile & refactor to sidebar (#854) * feat: add users page * feat: add user profile * fix: test * fix: remove log Co-authored-by: Graham Tackley * fix: use routerLink * fix: drawerWidth * fix: noopener * fix: use routerlink in register --------- Co-authored-by: Graham Tackley --- src/auth/context/auth.interface.ts | 1 + src/auth/hooks/queries/useUser.ts | 10 +- src/auth/index.tsx | 1 + src/auth/registration/Register.tsx | 70 +++-- src/auth/views/LandingPage.tsx | 4 +- src/auth/views/Login.tsx | 2 +- src/auth/views/MagicLink.tsx | 2 +- src/components/AppBar/LandingPageAppBar.tsx | 11 +- src/components/Card/CardContainer.tsx | 4 +- src/components/Drawer/MiniSideBar.tsx | 209 +++++++++++++ src/components/Navigation/DraftMenu.tsx | 28 +- src/components/Navigation/Navbar.tsx | 26 +- src/components/Steps/ActionButtons.tsx | 8 +- src/graphql/types.ts | 4 + src/graphql/user.generated.tsx | 140 +++++++++ src/graphql/user.graphql | 12 + src/theme.tsx | 8 + src/user/User.tsx | 4 + src/user/hooks/useGenerateApiKey.tsx | 45 +++ src/user/settings/NewKeyPairModal.tsx | 259 ++++++++++++++++ src/user/settings/Profile.tsx | 1 + src/user/settings/Settings.tsx | 322 +++----------------- src/user/settings/UserApiKey.tsx | 112 +++++++ src/user/settings/UserForm.tsx | 68 +++++ src/user/views/user/CampaignView.tsx | 9 +- src/user/views/user/Profile.tsx | 17 ++ src/validation/UserSchema.test.ts | 19 ++ src/validation/UserSchema.tsx | 6 + 28 files changed, 1037 insertions(+), 365 deletions(-) create mode 100644 src/components/Drawer/MiniSideBar.tsx create mode 100644 src/user/hooks/useGenerateApiKey.tsx create mode 100644 src/user/settings/NewKeyPairModal.tsx create mode 100644 src/user/settings/Profile.tsx create mode 100644 src/user/settings/UserApiKey.tsx create mode 100644 src/user/settings/UserForm.tsx create mode 100644 src/user/views/user/Profile.tsx create mode 100644 src/validation/UserSchema.test.ts create mode 100644 src/validation/UserSchema.tsx diff --git a/src/auth/context/auth.interface.ts b/src/auth/context/auth.interface.ts index 1a77ff5a..689c8e74 100644 --- a/src/auth/context/auth.interface.ts +++ b/src/auth/context/auth.interface.ts @@ -17,6 +17,7 @@ export interface IAuthState { isInitialized: boolean; isAuthenticated: boolean; setSessionUser: (u?: ResponseUser) => void; + fullName?: string; email?: string; role?: string; userId?: string; diff --git a/src/auth/hooks/queries/useUser.ts b/src/auth/hooks/queries/useUser.ts index a4acc5fc..0d06f479 100644 --- a/src/auth/hooks/queries/useUser.ts +++ b/src/auth/hooks/queries/useUser.ts @@ -1,11 +1,17 @@ import { useAuthContext } from "auth/context/auth.hook"; -type User = { userId?: string; role?: string; email?: string }; +type User = { + userId?: string; + role?: string; + email?: string; + fullName?: string; +}; export function useUser(): User { - const { userId, role, email } = useAuthContext(); + const { userId, role, email, fullName } = useAuthContext(); return { + fullName, userId, role, email, diff --git a/src/auth/index.tsx b/src/auth/index.tsx index 7059801e..518ddb72 100644 --- a/src/auth/index.tsx +++ b/src/auth/index.tsx @@ -42,6 +42,7 @@ export const IAuthProvider: React.FC = ({ email: u.email, role: u.role, userId: u.id, + fullName: u.fullName, advertisers: u.advertisers, isAuthenticated: u.advertisers.length > 0 && !!active, })); diff --git a/src/auth/registration/Register.tsx b/src/auth/registration/Register.tsx index 6b7f577f..90eff764 100644 --- a/src/auth/registration/Register.tsx +++ b/src/auth/registration/Register.tsx @@ -34,44 +34,42 @@ export function Register() { return ( - - - - - - {steps[activeStep].label} - - { - setSubmitting(true); - register(v); - setSubmitting(false); - }} - validationSchema={RegistrationSchema} - > -
- - {steps[activeStep].component} - + + + + + {steps[activeStep].label} + + { + setSubmitting(true); + register(v); + setSubmitting(false); + }} + validationSchema={RegistrationSchema} + > + + + {steps[activeStep].component} + - setActiveStep(activeStep + 1)} - onBack={() => setActiveStep(activeStep - 1)} - final={ - - } - /> + setActiveStep(activeStep + 1)} + onBack={() => setActiveStep(activeStep - 1)} + final={ + + } + /> - - - - + + +
); diff --git a/src/auth/views/LandingPage.tsx b/src/auth/views/LandingPage.tsx index f46a7311..18dbb82b 100644 --- a/src/auth/views/LandingPage.tsx +++ b/src/auth/views/LandingPage.tsx @@ -4,6 +4,7 @@ import React from "react"; import { Box, Button, Stack, Typography } from "@mui/material"; import goals from "../../../images.svg"; import { useIsAuthenticated } from "auth/hooks/queries/useIsAuthenticated"; +import { Link as RouterLink } from "react-router-dom"; const GradientText = { backgroundImage: @@ -40,13 +41,14 @@ export function LandingPage() { diff --git a/src/auth/views/Login.tsx b/src/auth/views/Login.tsx index 450892da..63b8da9b 100644 --- a/src/auth/views/Login.tsx +++ b/src/auth/views/Login.tsx @@ -45,7 +45,7 @@ export function Login() { color="primary" size="large" variant="contained" - sx={{ mt: 2, textTransform: "none", mb: 1 }} + sx={{ mt: 2, mb: 1 }} disabled={loading} loading={loading} onClick={() => { diff --git a/src/auth/views/MagicLink.tsx b/src/auth/views/MagicLink.tsx index c5a1337a..500edd2c 100644 --- a/src/auth/views/MagicLink.tsx +++ b/src/auth/views/MagicLink.tsx @@ -66,7 +66,7 @@ export function MagicLink() { color="primary" size="large" variant="contained" - sx={{ mt: 2, textTransform: "none", mb: 1 }} + sx={{ mt: 2, mb: 1 }} disabled={loading} loading={loading} onClick={() => { diff --git a/src/components/AppBar/LandingPageAppBar.tsx b/src/components/AppBar/LandingPageAppBar.tsx index 820258c8..de64e338 100644 --- a/src/components/AppBar/LandingPageAppBar.tsx +++ b/src/components/AppBar/LandingPageAppBar.tsx @@ -10,6 +10,7 @@ import { LinkProps, Stack, Toolbar, + Typography, } from "@mui/material"; import ads from "../../../branding.svg"; import { Link as RouterLink, useRouteMatch } from "react-router-dom"; @@ -23,7 +24,11 @@ export function LandingPageAppBar() { const links = [ { component: isAuthenticated ? null : ( - + + + Get started + + ), }, { @@ -97,8 +102,8 @@ function AuthedButton(props: { isAuthenticated?: boolean }) { setAnchorEl(null)}> Ads - {advertiser.selfServiceCreate && ( - <> - -
- - Need Help? - - selfserve@brave.com - - -
- - )} + {advertiser.selfServiceCreate && }
{advertiser.selfServiceCreate && ( @@ -66,13 +52,15 @@ export function Navbar() { onClick={() => history.push(newUrl)} size="medium" variant="contained" - sx={{ mr: 5 }} + sx={{ mr: 3 }} disabled={isNewCampaignPage || isCompletePage || !advertiser.agreed} > New Campaign )} - + ); diff --git a/src/components/Steps/ActionButtons.tsx b/src/components/Steps/ActionButtons.tsx index 009fce71..527a9ca2 100644 --- a/src/components/Steps/ActionButtons.tsx +++ b/src/components/Steps/ActionButtons.tsx @@ -12,18 +12,14 @@ export function ActionButtons() { return ( - {values.draftId !== undefined && ( + + + + closeNewKeypairModal()}> + + {newKeypairModalState === "disclaimer" && ( + + + Create new keypair? + + + + You are attempting to create a new keypair, this will replace + any of your organization's existing keypairs. Please note, + previous keypairs cannot be retrieved or used once replaced. + + + + + + + + + )} + {newKeypairModalState === "privateKey" && ( + + + Create new keypair? + + + + Your organization's new private key will be: + + + + + + Copy this and keep this safe! + + + Brave cannot recover this key, which has been generated in your + browser. You will need to confirm this private key on the next + step before changes are saved. + + + + + + + + )} + {newKeypairModalState === "confirmation" && ( + + + Create new keypair? + + + + Please confirm your organization's new private key: + + + setNewPrivateKey(e.target.value)} + /> + + + Once confirmed, your organization's keypair will be replaced + with the new keypair. + + + + + + + + + )} + + + + ); +} diff --git a/src/user/settings/Profile.tsx b/src/user/settings/Profile.tsx new file mode 100644 index 00000000..1c71c82f --- /dev/null +++ b/src/user/settings/Profile.tsx @@ -0,0 +1 @@ +export function Profile() {} diff --git a/src/user/settings/Settings.tsx b/src/user/settings/Settings.tsx index 7ef3f851..79d2fddd 100644 --- a/src/user/settings/Settings.tsx +++ b/src/user/settings/Settings.tsx @@ -1,315 +1,71 @@ -import { useContext, useState } from "react"; +import React, { useContext, useState } from "react"; import _ from "lodash"; -import * as tweetnacl from "tweetnacl"; -import { useUpdateAdvertiserMutation } from "graphql/advertiser.generated"; import { Box, - Button, Container, FormControl, InputLabel, MenuItem, - Modal, Select, SelectChangeEvent, Stack, - SxProps, - TextField, Typography, } from "@mui/material"; import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { setActiveAdvertiser } from "auth/util"; -import ArrowBack from "@mui/icons-material/ArrowBack"; -import { useHistory } from "react-router-dom"; import { CardContainer } from "components/Card/CardContainer"; import { DraftContext } from "state/context"; - -const modalStyles: SxProps = { - position: "absolute", - top: "50%", - left: "50%", - right: "auto", - bottom: "auto", - transform: "translate(-50%, -50%)", - width: 650, - bgcolor: "background.paper", - border: "2px solid #e2e2e2", - boxShadow: 24, - borderRadius: "4px", - p: 4, -}; +import { NewKeyPairModal } from "user/settings/NewKeyPairModal"; +import MiniSideBar from "components/Drawer/MiniSideBar"; const Settings = () => { const { advertiser: activeAdvertiser, advertisers } = useAdvertiser(); - const [saving, setSaving] = useState(false); - const [publicKey, setPublicKey] = useState(activeAdvertiser.publicKey); - const [advertiserId, setAdvertiserId] = useState(activeAdvertiser.id); - const [newPublicKey, setNewPublicKey] = useState(""); - const [newPrivateKey, setNewPrivateKey] = useState(""); - const [privateKey, setPrivateKey] = useState(""); - const [showNewKeypairModal, setShowNewKeypairModal] = useState(false); - const [newKeypairModalState, setNewKeypairModalState] = - useState("disclaimer"); + const [advertiser, setAdvertiser] = useState(activeAdvertiser); const { setDrafts } = useContext(DraftContext); - const history = useHistory(); - - const handleUpdateAdvertiser = () => { - setSaving(false); - setPublicKey(newPublicKey); - setPrivateKey(""); - setNewPrivateKey(""); - closeNewKeypairModal(); - window.location.reload(); - }; - - const [updateAdvertiser] = useUpdateAdvertiserMutation({ - variables: { - updateAdvertiserInput: { - id: advertiserId, - publicKey: publicKey, - }, - }, - onCompleted: handleUpdateAdvertiser, - onError() { - alert("Unable to update Advertiser."); - }, - }); - - const saveKeypair = () => { - setSaving(true); - updateAdvertiser({ - variables: { - updateAdvertiserInput: { - id: advertiserId, - publicKey: newPublicKey, - }, - }, - }); - }; - - const openNewKeypairModal = () => { - const keypair = tweetnacl.box.keyPair(); - const publicKey = btoa( - String.fromCharCode.apply(null, keypair.publicKey as unknown as number[]), - ); - const privateKey = btoa( - String.fromCharCode.apply(null, keypair.secretKey as unknown as number[]), - ); - setNewPublicKey(publicKey); - setPrivateKey(privateKey); - setShowNewKeypairModal(true); - }; - - const closeNewKeypairModal = () => { - setNewKeypairModalState("disclaimer"); - setShowNewKeypairModal(false); - }; const setActiveAdvertiserWithId = (e: SelectChangeEvent) => { const id = e.target.value; - setAdvertiserId(id); const adv = _.find(advertisers, { id }); - setPublicKey(adv?.publicKey); - setActiveAdvertiser(adv?.id); - setDrafts(); + + if (adv) { + setAdvertiser(adv); + setActiveAdvertiser(adv?.id); + setDrafts(); + } }; return ( - - - - - - Keypairs - - - - Generate a keypair for your organization. Brave Ads will use your - organization's public key to sign and encrypt conversion data. Only - your organization will have access to the private key, which can be - used to decrypt and view conversion data. - - - {publicKey !== "" && ( - - Your organization's public key: - - {publicKey} - - - )} - - - - - - - - You may have access to multiple organisations. Switch between them - here. - - - - - Select Organization - - - - - - - closeNewKeypairModal()}> - - {newKeypairModalState === "disclaimer" && ( - - - Create new keypair? - - - - You are attempting to create a new keypair, this will replace - any of your organization's existing keypairs. Please note, - previous keypairs cannot be retrieved or used once replaced. - - - - - - - - - )} - {newKeypairModalState === "privateKey" && ( - - - Create new keypair? - - - - Your organization's new private key will be: - - - - - - Copy this and keep this safe! - - - Brave cannot recover this key, which has been generated in your - browser. You will need to confirm this private key on the next - step before changes are saved. - - - - - - - - )} - {newKeypairModalState === "confirmation" && ( - - - Create new keypair? - - - - Please confirm your organization's new private key: - - - setNewPrivateKey(e.target.value)} - /> - - - Once confirmed, your organization's keypair will be replaced - with the new keypair. - - - - - - + + setOpen(false)} maxWidth="lg"> + + {data ? "New API Key" : "Generate a new API key?"} + + + {!data && ( + + Generating a new API key will result in the deactivation of your + previous key, rendering it unusable for future requests. Make sure + to update your code with the new key to avoid disruptions in your + application's functionality. + + )} + {data && ( + + + This key is unique to you, make sure to safely store it and + avoid sharing it with others to prevent unauthorized access. + + + {data} + + window.navigator.clipboard + .writeText(data) + .then(() => alert("Key copied")) + } + sx={{ ml: 1 }} + > + + + + + )} + + + + {!data && ( + generate(advertiser.id)} + > + Generate + + )} + + + + ); +} diff --git a/src/user/settings/UserForm.tsx b/src/user/settings/UserForm.tsx new file mode 100644 index 00000000..b7f945b5 --- /dev/null +++ b/src/user/settings/UserForm.tsx @@ -0,0 +1,68 @@ +import { Stack } from "@mui/material"; +import React, { useState } from "react"; +import { useUser } from "auth/hooks/queries/useUser"; +import { CardContainer } from "components/Card/CardContainer"; +import { Form, Formik, FormikValues } from "formik"; +import { FormikSubmitButton, FormikTextField } from "form/FormikHelpers"; +import { useUpdateUserMutation } from "graphql/user.generated"; +import { ErrorDetail } from "components/Error/ErrorDetail"; +import { UserSchema } from "validation/UserSchema"; +import _ from "lodash"; + +export function UserForm() { + const user = useUser(); + const [initialVals, setInitialVals] = useState(user); + + if (!user.userId) { + const details = "Unable to get profile information"; + return ; + } + + const [updateUser] = useUpdateUserMutation({ + onCompleted(user) { + setInitialVals(user.updateUser); + }, + }); + + return ( + + { + setSubmitting(true); + updateUser({ + variables: { input: { id: user.userId, ..._.omit(v, ["userId"]) } }, + }).finally(() => setSubmitting(false)); + }} + validationSchema={UserSchema} + > +
+ + + + + + + + + +
+
+ ); +} diff --git a/src/user/views/user/CampaignView.tsx b/src/user/views/user/CampaignView.tsx index 1525c61d..daf0ae0c 100644 --- a/src/user/views/user/CampaignView.tsx +++ b/src/user/views/user/CampaignView.tsx @@ -6,6 +6,7 @@ import { CampaignList } from "user/campaignList/CampaignList"; import { ErrorDetail } from "components/Error/ErrorDetail"; import moment from "moment/moment"; import { CardContainer } from "components/Card/CardContainer"; +import MiniSideBar from "components/Drawer/MiniSideBar"; export function CampaignView() { const [fromDateFilter, setFromDateFilter] = useState( @@ -31,9 +32,13 @@ export function CampaignView() { } return ( - + )} - + ); } diff --git a/src/user/views/user/Profile.tsx b/src/user/views/user/Profile.tsx new file mode 100644 index 00000000..8d08c2e1 --- /dev/null +++ b/src/user/views/user/Profile.tsx @@ -0,0 +1,17 @@ +import { Container } from "@mui/material"; +import { UserForm } from "user/settings/UserForm"; +import { UserApiKey } from "user/settings/UserApiKey"; +import MiniSideBar from "components/Drawer/MiniSideBar"; + +export function Profile() { + return ( + + +
+ +
+ + + + ); +} diff --git a/src/validation/UserSchema.test.ts b/src/validation/UserSchema.test.ts new file mode 100644 index 00000000..4b23567f --- /dev/null +++ b/src/validation/UserSchema.test.ts @@ -0,0 +1,19 @@ +import { UserSchema } from "./UserSchema"; +import { produce } from "immer"; + +const validUser = { + email: "test@brave.com", + fullName: "Test User", +}; + +it("should pass on a valid object", () => { + UserSchema.validateSync(validUser); +}); + +it("should fail if the campaign format is invalid", () => { + const c = produce(validUser, (draft) => { + draft.email = ""; + }); + + expect(() => UserSchema.validateSync(c)).toThrowError(); +}); diff --git a/src/validation/UserSchema.tsx b/src/validation/UserSchema.tsx new file mode 100644 index 00000000..4875d3ff --- /dev/null +++ b/src/validation/UserSchema.tsx @@ -0,0 +1,6 @@ +import { object, string } from "yup"; + +export const UserSchema = object().shape({ + email: string().label("Email").required(), + fullName: string().label("Full Name").required(), +});