Skip to content

Commit

Permalink
Add email confirmation workflow (#651)
Browse files Browse the repository at this point in the history
* Backend work

* Frontend email verification

* lint fixes

* Backend tests
  • Loading branch information
edlouth authored Sep 19, 2023
1 parent 1ffd010 commit 70c7c99
Show file tree
Hide file tree
Showing 14 changed files with 422 additions and 5 deletions.
2 changes: 2 additions & 0 deletions grai-frontend/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const Register = lazy(() => import("./pages/auth/Register"))
const ForgotPassword = lazy(() => import("./pages/auth/ForgotPassword"))
const PasswordReset = lazy(() => import("./pages/auth/PasswordReset"))
const CompleteSignup = lazy(() => import("./pages/auth/CompleteSignup"))
const VerifyEmail = lazy(() => import("./pages/auth/VerifyEmail"))

const SentryRoutes = Sentry.withSentryReactRouterV6Routing(BrowerRoutes)

Expand Down Expand Up @@ -170,6 +171,7 @@ const Routes: React.FC = () => (
<Route path="post-install" element={<PostInstall />} />
<Route path="*" element={<NotFound />} />
</Route>
<Route path="email-verification" element={<VerifyEmail />} />
</Route>
</Route>

Expand Down
9 changes: 8 additions & 1 deletion grai-frontend/src/components/settings/twoFactor/Test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from "@mui/material"
import Form from "components/form/Form"
import GraphError from "components/utils/GraphError"
import {
ConfirmDevice,
ConfirmDeviceVariables,
} from "./__generated__/ConfirmDevice"

export const CONFIRM_DEVICE = gql`
mutation ConfirmDevice($deviceId: ID!, $token: String!) {
Expand All @@ -32,7 +36,10 @@ type TestProps = {
const Test: React.FC<TestProps> = ({ device, onBack, onClose }) => {
const [token, setToken] = useState("")

const [confirmDevice, { loading, error }] = useMutation(CONFIRM_DEVICE)
const [confirmDevice, { loading, error }] = useMutation<
ConfirmDevice,
ConfirmDeviceVariables
>(CONFIRM_DEVICE)

const handleSubmit = () => {
confirmDevice({
Expand Down
64 changes: 64 additions & 0 deletions grai-frontend/src/pages/auth/VerifyEmail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from "react"
import { GraphQLError } from "graphql"
import { render, screen, waitFor } from "testing"
import VerifyEmail, { VERIFY_EMAIL } from "./VerifyEmail"

test("renders", async () => {
render(<VerifyEmail />, {
route: "/email-verification?token=abc&uid=1234",
path: "email-verification",
routes: ["/"],
})

expect(screen.getByRole("progressbar")).toBeTruthy()

await screen.findByText("New Page")
})

test("missing token", async () => {
render(<VerifyEmail />, {
route: "/email-verification?uid=1234",
path: "email-verification",
routes: ["/"],
})

await screen.findByText("Missing required token")
})

test("missing uid", async () => {
render(<VerifyEmail />, {
route: "/email-verification?token=abc",
path: "email-verification",
routes: ["/"],
})

await screen.findByText("Missing required token")
})

test("error", async () => {
const mocks = [
{
request: {
query: VERIFY_EMAIL,
variables: {
uid: "1234",
token: "abc",
},
},
result: {
errors: [new GraphQLError("Error!")],
},
},
]

render(<VerifyEmail />, {
route: "/email-verification?token=abc&uid=1234",
path: "email-verification",
routes: ["/"],
mocks,
})

await waitFor(() => {
expect(screen.getByText("Error!")).toBeInTheDocument()
})
})
52 changes: 52 additions & 0 deletions grai-frontend/src/pages/auth/VerifyEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useEffect } from "react"
import { gql, useMutation } from "@apollo/client"
import { useSnackbar } from "notistack"
import { useNavigate, useSearchParams } from "react-router-dom"
import Loading from "components/layout/Loading"
import GraphError from "components/utils/GraphError"
import { Verify, VerifyVariables } from "./__generated__/Verify"

export const VERIFY_EMAIL = gql`
mutation Verify($uid: String!, $token: String!) {
verifyEmail(uid: $uid, token: $token) {
id
}
}
`

const VerifyEmail: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { enqueueSnackbar } = useSnackbar()

const [verifyEmail, { error }] = useMutation<Verify, VerifyVariables>(
VERIFY_EMAIL,
)

const uid = searchParams.get("uid")
const token = searchParams.get("token")

useEffect(() => {
if (!uid || !token) return

verifyEmail({
variables: {
uid,
token,
},
})
.then(() => enqueueSnackbar("Email verified", { variant: "success" }))
.then(() => {
navigate("/")
})
.catch(() => {})
}, [uid, token, verifyEmail, navigate, enqueueSnackbar])

if (error) return <GraphError error={error} />

if (!uid || !token) return <>Missing required token</>

return <Loading />
}

export default VerifyEmail
22 changes: 22 additions & 0 deletions grai-frontend/src/pages/auth/__generated__/Verify.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions grai-frontend/src/pages/auth/__generated__/VerifyEmail.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions grai-frontend/src/testing/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9257,6 +9257,51 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "verifyEmail",
"description": null,
"args": [
{
"name": "uid",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "token",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createDevice",
"description": null,
Expand Down
23 changes: 23 additions & 0 deletions grai-server/app/auth/mutations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Union

import strawberry
Expand All @@ -19,6 +20,8 @@
from api.types import BasicResult, User
from users.types import Device

from .validation import send_validation_email, verification_generator


@strawberry.type
class DeviceWithUrl(Device):
Expand Down Expand Up @@ -108,6 +111,8 @@ async def register(

await sync_to_async(login)(info.context.request, user)

await sync_to_async(send_validation_email)(user)

return user

@strawberry.mutation(permission_classes=[IsAuthenticated])
Expand Down Expand Up @@ -195,11 +200,29 @@ async def completeSignup(self, token: str, uid: str, first_name: str, last_name:
user.last_name = last_name
user.set_password(password)
await sync_to_async(user.save)()

send_validation_email(user)

return user

except UserModel.DoesNotExist:
raise Exception("User not found")

@strawberry.mutation(permission_classes=[IsAuthenticated])
async def verifyEmail(self, info: Info, uid: str, token: str) -> User:
user = get_user(info)

if not str(user.pk) == uid:
raise Exception("Incorrect user")

if not verification_generator.check_token(user, token):
raise Exception("Token invalid")

user.verified_at = datetime.now()
await sync_to_async(user.save)()

return user

@strawberry.mutation(permission_classes=[IsAuthenticated])
async def createDevice(self, info: Info, name: str) -> DeviceWithUrl:
def _create(info: Info, name: str) -> DeviceWithUrl:
Expand Down
Loading

0 comments on commit 70c7c99

Please sign in to comment.