From 155a273e79f5e307d14db10da5057ef730173df2 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:10:33 -0400 Subject: [PATCH 1/2] feat: persist registration form on refresh and error (#853) * feat: persist registration form on refresh and error * fix: no need for function --- src/auth/hooks/mutations/useRegister.ts | 6 ++++- src/auth/registration/Register.tsx | 3 +++ src/form/PersistRegistrationValues.tsx | 29 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/form/PersistRegistrationValues.tsx diff --git a/src/auth/hooks/mutations/useRegister.ts b/src/auth/hooks/mutations/useRegister.ts index efad86cf..baf7a04c 100644 --- a/src/auth/hooks/mutations/useRegister.ts +++ b/src/auth/hooks/mutations/useRegister.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { RegistrationForm } from "auth/registration/types"; import { submitRegistration } from "auth/lib"; +import { clearRegistrationValues } from "form/PersistRegistrationValues"; export function useRegister() { const [hasRegistered, setHasRegistered] = useState(false); @@ -10,7 +11,10 @@ export function useRegister() { const register = useCallback((form: RegistrationForm) => { setLoading(true); submitRegistration(form) - .then(() => setHasRegistered(true)) + .then(() => { + setHasRegistered(true); + clearRegistrationValues(); + }) .catch((e) => { setError(e.message); }) diff --git a/src/auth/registration/Register.tsx b/src/auth/registration/Register.tsx index 6dea71ee..6b7f577f 100644 --- a/src/auth/registration/Register.tsx +++ b/src/auth/registration/Register.tsx @@ -13,6 +13,7 @@ import { Box, Toolbar, Typography } from "@mui/material"; import { Background } from "components/Background/Background"; import { LandingPageAppBar } from "components/AppBar/LandingPageAppBar"; import { PaddedCardContainer } from "components/Card/PaddedCardContainer"; +import { PersistRegistrationValues } from "form/PersistRegistrationValues"; export function Register() { const [activeStep, setActiveStep] = useState(0); @@ -66,6 +67,8 @@ export function Register() { /> } /> + + diff --git a/src/form/PersistRegistrationValues.tsx b/src/form/PersistRegistrationValues.tsx new file mode 100644 index 00000000..44816233 --- /dev/null +++ b/src/form/PersistRegistrationValues.tsx @@ -0,0 +1,29 @@ +import { useFormikContext } from "formik"; +import React, { useEffect } from "react"; +import { RegistrationForm } from "auth/registration/types"; +import _ from "lodash"; + +export const PersistRegistrationValues = () => { + const { values, setValues, dirty } = useFormikContext(); + + // read the values from localStorage on load + useEffect(() => { + const form = localStorage.getItem("registerInProgress"); + if (form) { + setValues(JSON.parse(form)); + } + }, []); + + // save the values to localStorage on update + useEffect(() => { + if (!_.isEmpty(values) && dirty) { + localStorage.setItem("registerInProgress", JSON.stringify(values)); + } + }, [values, dirty]); + + return null; +}; + +export const clearRegistrationValues = () => { + localStorage.removeItem("registerInProgress"); +}; 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 2/2] 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() { {isAuthenticated ? "Dashboard" : "Get Started"} 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 }) { signOut() : undefined} > {props.isAuthenticated ? "Sign out" : "Log in"} diff --git a/src/components/Card/CardContainer.tsx b/src/components/Card/CardContainer.tsx index f227a0fa..8e3e40f9 100644 --- a/src/components/Card/CardContainer.tsx +++ b/src/components/Card/CardContainer.tsx @@ -1,14 +1,16 @@ import React, { PropsWithChildren } from "react"; import { Box, Card, CardContent, Stack, Typography } from "@mui/material"; +import { SxProps } from "@mui/system"; export function CardContainer( props: { header?: string; additionalAction?: React.ReactNode; + sx?: SxProps; } & PropsWithChildren, ) { return ( - + {(props.header || props.additionalAction) && ( ) => void; +}; + +const drawerWidth = 120; +export default function MiniSideBar({ children }: PropsWithChildren) { + const dashboardRoutes: RouteOption[] = [ + { + label: "Campaigns", + href: "/user/main/campaign", + icon: ( + + ), + }, + // Possible future enhancements, not visible to user but help keep spacing + { + label: "Creatives", + href: "/user/main/creatives", + icon: ( + + ), + disabled: true, + }, + { + label: "Assets", + href: "/user/main/assets", + icon: ( + + ), + disabled: true, + }, + { + label: "Audiences", + href: "/user/main/audiences", + icon: ( + + ), + disabled: true, + }, + ]; + + const settingsRoutes: RouteOption[] = [ + { + label: "Account", + href: "/user/main/settings", + icon: ( + + ), + }, + { + label: "Profile", + href: "/user/main/profile", + icon: ( + + ), + }, + ]; + + return ( + + + + + {dashboardRoutes.map((dr) => ( + + ))} + + {settingsRoutes.map((sr) => ( + + ))} + + + + {children} + + ); +} + +const ItemBox = (props: RouteOption) => { + const match = useRouteMatch(); + return ( + + {props.icon} + + {props.label} + + + ); +}; + +export function SupportMenu() { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + return ( + <> + + } + onClick={handleClick} + /> + setAnchorEl(null)}> + { + window.open("https://brave.com/brave-ads", "_blank", "noopener"); + setAnchorEl(null); + }} + > + Advertiser Resources + + { + window.open("mailto:selfserve@brave.com", "_self", "noopener"); + setAnchorEl(null); + }} + > + Email support:{" "} + + selfserve@brave.com + + + + > + ); +} diff --git a/src/components/Navigation/DraftMenu.tsx b/src/components/Navigation/DraftMenu.tsx index 65c814f6..226d27d5 100644 --- a/src/components/Navigation/DraftMenu.tsx +++ b/src/components/Navigation/DraftMenu.tsx @@ -2,7 +2,14 @@ import * as React from "react"; import { useContext, useState } from "react"; import { useHistory } from "react-router-dom"; -import { Badge, IconButton, Menu, MenuItem, Tooltip } from "@mui/material"; +import { + Badge, + Button, + IconButton, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; import DraftsIcon from "@mui/icons-material/Drafts"; import { DraftContext } from "state/context"; @@ -18,15 +25,16 @@ export function DraftMenu() { return ( <> - - - - - - - - - + + } + > + Campaign Drafts + setAnchorEl(null)}> - {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 )} - + signOut()}> + Sign out + ); 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 ( - history.push("/user/main")} - > + history.push("/user/main")}> Return to dashboard {values.draftId !== undefined && ( { localStorage.removeItem(values.draftId!); setDrafts(); diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 8ed21fc1..d1345779 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -361,6 +361,10 @@ export type RejectCampaignInput = { option: CampaignRejection; }; +export enum RequestedDimensions { + Ad = "AD", +} + export type SearchHomepagePayloadInput = { body: Scalars["String"]; ctaText?: Scalars["String"]; diff --git a/src/graphql/user.generated.tsx b/src/graphql/user.generated.tsx index 8126dd3a..6b6cb91c 100644 --- a/src/graphql/user.generated.tsx +++ b/src/graphql/user.generated.tsx @@ -1,6 +1,8 @@ import * as Types from "./types"; import { gql } from "@apollo/client"; +import * as Apollo from "@apollo/client"; +const defaultOptions = {} as const; export type UserFragment = { __typename?: "User"; email: string; @@ -9,6 +11,36 @@ export type UserFragment = { role: string; }; +export type LoadUserQueryVariables = Types.Exact<{ + id: Types.Scalars["String"]; +}>; + +export type LoadUserQuery = { + __typename?: "Query"; + user: { + __typename?: "User"; + email: string; + fullName: string; + id: string; + role: string; + }; +}; + +export type UpdateUserMutationVariables = Types.Exact<{ + input: Types.UpdateUserInput; +}>; + +export type UpdateUserMutation = { + __typename?: "Mutation"; + updateUser: { + __typename?: "User"; + email: string; + fullName: string; + id: string; + role: string; + }; +}; + export const UserFragmentDoc = gql` fragment User on User { email @@ -17,3 +49,111 @@ export const UserFragmentDoc = gql` role } `; +export const LoadUserDocument = gql` + query LoadUser($id: String!) { + user(id: $id) { + ...User + } + } + ${UserFragmentDoc} +`; + +/** + * __useLoadUserQuery__ + * + * To run a query within a React component, call `useLoadUserQuery` and pass it any options that fit your needs. + * When your component renders, `useLoadUserQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useLoadUserQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useLoadUserQuery( + baseOptions: Apollo.QueryHookOptions, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + LoadUserDocument, + options, + ); +} +export function useLoadUserLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + LoadUserQuery, + LoadUserQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery( + LoadUserDocument, + options, + ); +} +export type LoadUserQueryHookResult = ReturnType; +export type LoadUserLazyQueryHookResult = ReturnType< + typeof useLoadUserLazyQuery +>; +export type LoadUserQueryResult = Apollo.QueryResult< + LoadUserQuery, + LoadUserQueryVariables +>; +export function refetchLoadUserQuery(variables: LoadUserQueryVariables) { + return { query: LoadUserDocument, variables: variables }; +} +export const UpdateUserDocument = gql` + mutation UpdateUser($input: UpdateUserInput!) { + updateUser(updateUserInput: $input) { + ...User + } + } + ${UserFragmentDoc} +`; +export type UpdateUserMutationFn = Apollo.MutationFunction< + UpdateUserMutation, + UpdateUserMutationVariables +>; + +/** + * __useUpdateUserMutation__ + * + * To run a mutation, you first call `useUpdateUserMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateUserMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateUserMutation, { data, loading, error }] = useUpdateUserMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateUserMutation( + baseOptions?: Apollo.MutationHookOptions< + UpdateUserMutation, + UpdateUserMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation( + UpdateUserDocument, + options, + ); +} +export type UpdateUserMutationHookResult = ReturnType< + typeof useUpdateUserMutation +>; +export type UpdateUserMutationResult = + Apollo.MutationResult; +export type UpdateUserMutationOptions = Apollo.BaseMutationOptions< + UpdateUserMutation, + UpdateUserMutationVariables +>; diff --git a/src/graphql/user.graphql b/src/graphql/user.graphql index 36c6dbfc..eef6a38f 100644 --- a/src/graphql/user.graphql +++ b/src/graphql/user.graphql @@ -4,3 +4,15 @@ fragment User on User { id role } + +query LoadUser($id: String!) { + user(id: $id) { + ...User + } +} + +mutation UpdateUser($input: UpdateUserInput!) { + updateUser(updateUserInput: $input) { + ...User + } +} diff --git a/src/theme.tsx b/src/theme.tsx index 1936af79..c313239e 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -33,6 +33,7 @@ export const theme = createTheme({ styleOverrides: { root: { borderRadius: "1000px", + textTransform: "none", }, }, }, @@ -44,5 +45,12 @@ export const theme = createTheme({ }, }, }, + MuiContainer: { + styleOverrides: { + root: { + marginLeft: 0, + }, + }, + }, }, }); diff --git a/src/user/User.tsx b/src/user/User.tsx index a288770c..4e890777 100644 --- a/src/user/User.tsx +++ b/src/user/User.tsx @@ -17,6 +17,8 @@ import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { Navbar } from "components/Navigation/Navbar"; import { CampaignView } from "user/views/user/CampaignView"; import { CampaignReportView } from "user/views/user/CampaignReportView"; +import MiniSideBar from "components/Drawer/MiniSideBar"; +import { Profile } from "user/views/user/Profile"; const buildApolloClient = () => { const httpLink = createHttpLink({ @@ -69,6 +71,8 @@ export function User() { + + (); + const [error, setError] = useState(); + + const generate = useCallback((advertiserId: string) => { + setLoading(true); + createKey(advertiserId) + .then((res) => { + setData(res); + }) + .catch((error) => { + setError(error.message); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return { generate, data, loading, error }; +} + +async function createKey(advertiserId: string): Promise { + const res = await fetch(buildAdServerV2Endpoint("/auth/api-key"), { + method: "POST", + mode: "cors", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + advertiserId, + }), + }); + + if (res.status !== 200) { + throw new Error("cannot create key"); + } + + const { apiKey } = await res.json(); + return apiKey; +} diff --git a/src/user/settings/NewKeyPairModal.tsx b/src/user/settings/NewKeyPairModal.tsx new file mode 100644 index 00000000..17d58937 --- /dev/null +++ b/src/user/settings/NewKeyPairModal.tsx @@ -0,0 +1,259 @@ +import { + Box, + Button, + Modal, + Stack, + SxProps, + TextField, + Typography, +} from "@mui/material"; +import { useUpdateAdvertiserMutation } from "graphql/advertiser.generated"; +import * as tweetnacl from "tweetnacl"; +import { useRef, useState } from "react"; +import { IAdvertiser } from "auth/context/auth.interface"; +import { CardContainer } from "components/Card/CardContainer"; + +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, +}; + +interface Props { + advertiser: IAdvertiser; +} + +export function NewKeyPairModal({ advertiser }: Props) { + const [saving, setSaving] = useState(false); + const publicKey = useRef(); + publicKey.current = advertiser.publicKey; + const [newPublicKey, setNewPublicKey] = useState(""); + const [newPrivateKey, setNewPrivateKey] = useState(""); + const [privateKey, setPrivateKey] = useState(""); + const [showNewKeypairModal, setShowNewKeypairModal] = useState(false); + const [newKeypairModalState, setNewKeypairModalState] = + useState("disclaimer"); + + const [updateAdvertiser] = useUpdateAdvertiserMutation({ + variables: { + updateAdvertiserInput: { + id: advertiser.id, + publicKey: publicKey.current, + }, + }, + onCompleted() { + publicKey.current = newPublicKey; + setPrivateKey(""); + setNewPrivateKey(""); + closeNewKeypairModal(); + window.location.reload(); + }, + onError() { + alert("Unable to update Advertiser."); + }, + }); + + const saveKeypair = () => { + setSaving(true); + updateAdvertiser({ + variables: { + updateAdvertiserInput: { + id: advertiser.id, + 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); + }; + + 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.current !== "" && ( + + Your organization's public key: + + {publicKey.current} + + + )} + + openNewKeypairModal()} + variant="contained" + style={{ + marginTop: "22px", + width: "300px", + alignSelf: "center", + }} + > + New Keypair + + + + + 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. + + + + + Cancel + + + setNewKeypairModalState("privateKey")} + > + Continue + + + + )} + {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. + + + + Cancel + + + setNewKeypairModalState("confirmation")} + > + Continue + + + + )} + {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. + + + + + Cancel + + + saveKeypair()} + disabled={saving || privateKey !== newPrivateKey} + > + {saving ? "Saving..." : "Save"} + + + + )} + + + > + ); +} 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 ( - - } - onClick={() => history.replace("/user/main")} - > - Dashboard - - - - - 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} - - - )} - - openNewKeypairModal()} - variant="contained" - style={{ - marginTop: "22px", - width: "300px", - alignSelf: "center", - }} - > - New Keypair - - - - - - - You may have access to multiple organisations. Switch between them - here. - - - - - Select Organization - setActiveAdvertiserWithId(e)} - > - {advertisers.map((a) => ( - - {a.name} - - ))} - - - - - - - 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. - - - - - Cancel - - - setNewKeypairModalState("privateKey")} - > - Continue - - - - )} - {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. - - - - Cancel - - - setNewKeypairModalState("confirmation")} - > - Continue - - - - )} - {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. - - - - - Cancel - - - saveKeypair()} - disabled={saving || privateKey !== newPrivateKey} + + + + + + + + You may have access to multiple organisations. Switch between them + here. + + + + + Select Organization + setActiveAdvertiserWithId(e)} > - {saving ? "Saving..." : "Save"} - - + {advertisers.map((a) => ( + + {a.name} + + ))} + + - )} - - - + + + + ); }; diff --git a/src/user/settings/UserApiKey.tsx b/src/user/settings/UserApiKey.tsx new file mode 100644 index 00000000..4c0cd807 --- /dev/null +++ b/src/user/settings/UserApiKey.tsx @@ -0,0 +1,112 @@ +import React, { useState } from "react"; +import { CardContainer } from "components/Card/CardContainer"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Link, + Typography, +} from "@mui/material"; +import { LoadingButton } from "@mui/lab"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { useGenerateApiKey } from "user/hooks/useGenerateApiKey"; +import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; + +export function UserApiKey() { + const { advertiser } = useAdvertiser(); + const { generate, data, loading } = useGenerateApiKey(); + const [open, setOpen] = useState(false); + + return ( + + + API keys are used to get data from the reporting endpoints. They are + unique to you. + + + + + + Documentation can be found{" "} + + here + {" "} + on how to use the API. + + + + + setOpen(true)} variant="contained" size="large"> + Generate API Key + + + 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 }} + > + + + + + )} + + + setOpen(false)}> + {data ? "Close" : "Cancel"} + + {!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(), +});