Skip to content

Commit

Permalink
feat(dasboard): adding totp in dashboard (#3696)
Browse files Browse the repository at this point in the history
* feat(dasboard): adding totp in dashboard

* chore: added protected

* feat: Ui improvements

* chore: fix testid

* chore: misc changes

* chore: change nextjs version

rebase

* feat: fix qr and added multiple identifier

* chore: lint fix

---------

Co-authored-by: Siddharth <[email protected]>
  • Loading branch information
siddhart1o1 and Siddharth authored Dec 14, 2023
1 parent 49e4029 commit ffce1c5
Show file tree
Hide file tree
Showing 12 changed files with 811 additions and 81 deletions.
2 changes: 1 addition & 1 deletion apps/consent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"@types/react-dom": "18.2.17",
"@types/react-phone-number-input": "^3.0.17",
"autoprefixer": "10.4.16",
"cypress": "^13.3.0",
"cypress": "^13.6.1",
"eslint": "8.55.0",
"eslint-config-next": "14.0.4",
"postcss": "8.4.32",
Expand Down
38 changes: 38 additions & 0 deletions apps/dashboard/app/security/2fa/add/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getServerSession } from "next-auth"
import { redirect } from "next/navigation"

import { totpRegisterInitiateServerAction } from "../server-actions"

import VerifyTwoFactorAuth from "./verify-form"

import { authOptions } from "@/app/api/auth/[...nextauth]/route"

export default async function VerifyTwoFactorAuthPage() {
const session = await getServerSession(authOptions)
const token = session?.accessToken
const totpEnabled = session?.userData.data.me?.totpEnabled
const totpIdentifier =
session?.userData.data.me?.username ||
session?.userData.data.me?.email?.address ||
session?.userData.data.me?.phone ||
session?.userData.data.me?.id ||
"Blink User"

if (!token || typeof token !== "string" || totpEnabled === true) {
redirect("/security")
}

const { error, responsePayload, message } = await totpRegisterInitiateServerAction()
if (error || !responsePayload?.totpRegistrationId || !responsePayload?.totpSecret) {
console.error(message)
const errorMessage = message || "Something Went Wrong"
throw new Error(errorMessage)
}
return (
<VerifyTwoFactorAuth
totpRegistrationId={responsePayload.totpRegistrationId}
totpSecret={responsePayload.totpSecret}
totpIdentifier={totpIdentifier}
></VerifyTwoFactorAuth>
)
}
210 changes: 210 additions & 0 deletions apps/dashboard/app/security/2fa/add/verify-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"use client"

import ArrowForwardIcon from "@mui/icons-material/ArrowForward"
import { useFormState } from "react-dom"
import CopyIcon from "@mui/icons-material/CopyAll"

import {
Box,
Button,
Input,
FormControl,
FormHelperText,
Typography,
Tooltip,
Card,
} from "@mui/joy"
import InfoOutlined from "@mui/icons-material/InfoOutlined"
import Link from "next/link"

import { QRCode } from "react-qrcode-logo"

import { useState } from "react"

import { totpRegisterValidateServerAction } from "../server-actions"

import { TotpValidateResponse } from "../totp.types"

import FormSubmitButton from "@/components/form-submit-button"

type VerifyTwoFactorAuth = {
totpRegistrationId: string
totpSecret: string
totpIdentifier: string
}

const AuthenticatorQRCode = ({
account,
secret,
}: {
account: string
secret: string
}) => {
return `otpauth://totp/Blink:${account}?secret=${secret}&issuer=Blink`
}

export default function VerifyTwoFactorAuth({
totpRegistrationId,
totpSecret,
totpIdentifier,
}: VerifyTwoFactorAuth) {
const [copied, setCopied] = useState(false)
const [state, formAction] = useFormState<TotpValidateResponse, FormData>(
totpRegisterValidateServerAction,
{
error: false,
message: null,
responsePayload: null,
},
)

return (
<main
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
marginTop: "4em",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: "30em",
gap: "1.5em",
}}
>
<Typography level="h2">Enter 2FA Code</Typography>
<Box>
<Typography
sx={{
textAlign: "center",
}}
>
Authenticator Secret Key
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
columnGap: 2,
backgroundColor: "neutral.solidDisabledBg",
padding: "0.6em",
borderRadius: "0.5em",
}}
>
<Typography
sx={{
width: "100%",
textAlign: "center",
}}
fontFamily="monospace"
>
{totpSecret}
</Typography>
<Tooltip
sx={{ cursor: "pointer" }}
title="Copied to Clipboard"
variant="plain"
open={copied}
onClick={() => {
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 2000)
navigator.clipboard.writeText(totpSecret ?? "")
}}
>
<CopyIcon />
</Tooltip>
</Box>
</Box>

<Box>
<QRCode
size={300}
value={AuthenticatorQRCode({
account: totpIdentifier,
secret: totpSecret,
})}
/>
</Box>
<FormControl
sx={{
width: "90%",
}}
error={state.error}
>
<form action={formAction}>
<input type="hidden" name="totpRegistrationId" value={totpRegistrationId} />
<Input
data-testid="security-add-totp-verification-code-input"
name="code"
type="code"
sx={{
padding: "0.6em",
width: "100%",
}}
placeholder="Please Enter Authenticator Code"
/>

{state.error ? (
<FormHelperText>
<InfoOutlined />
{state.message}
</FormHelperText>
) : null}

<Box
sx={{
display: "flex",
justifyContent: "space-between",
width: "100%",
alignItems: "center",
}}
>
<Link href={"/security"} style={{ width: "49%" }}>
<Button
type="submit"
name="submit"
color="danger"
variant="outlined"
sx={{
marginTop: "1em",
display: "flex",
gap: "1em",
width: "100%",
}}
>
Cancel
</Button>
</Link>
<FormSubmitButton
data-testid="security-add-totp-verification-code-confirm-btn"
type="submit"
name="submit"
sx={{
marginTop: "1em",
display: "flex",
gap: "1em",
width: "49%",
}}
>
Confirm <ArrowForwardIcon></ArrowForwardIcon>
</FormSubmitButton>
</Box>
</form>
</FormControl>
<Card>
To enable two-factor authentication (2FA), add the secret key to your
Authenticator App manually or use the app to scan the provided QR code. Once
added, enter the code generated by the Authenticator App to complete the setup.
</Card>
</Box>
</main>
)
}
132 changes: 132 additions & 0 deletions apps/dashboard/app/security/2fa/server-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use server"
import { getServerSession } from "next-auth"
import { redirect } from "next/navigation"

import { revalidatePath } from "next/cache"

import { TotpRegisterResponse, TotpValidateResponse } from "./totp.types"

import {
userTotpRegistrationInitiate,
userTotpRegistrationValidate,
userTotpDelete,
} from "@/services/graphql/mutations/totp"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"

import {
UserTotpRegistrationInitiateMutation,
UserTotpRegistrationValidateMutation,
} from "@/services/graphql/generated"

export const totpRegisterInitiateServerAction =
async (): Promise<TotpRegisterResponse> => {
const session = await getServerSession(authOptions)
const token = session?.accessToken
if (!token && typeof token !== "string") {
return {
error: true,
message: "Invalid Token",
responsePayload: null,
}
}

let data: UserTotpRegistrationInitiateMutation | null | undefined
try {
data = await userTotpRegistrationInitiate(token)
} catch (err) {
console.log("error in userTotpRegistrationInitiate ", err)
return {
error: true,
message:
"Something went wrong. Please try again. If the error persists, contact support.",
responsePayload: null,
}
}

if (data?.userTotpRegistrationInitiate.errors.length) {
return {
error: true,
message: data?.userTotpRegistrationInitiate.errors[0].message,
responsePayload: null,
}
}

const totpRegistrationId = data?.userTotpRegistrationInitiate.totpRegistrationId
const totpSecret = data?.userTotpRegistrationInitiate.totpSecret
return {
error: false,
message: "",
responsePayload: {
totpRegistrationId,
totpSecret,
},
}
}

export const totpRegisterValidateServerAction = async (
_prevState: TotpValidateResponse,
form: FormData,
): Promise<TotpValidateResponse> => {
const totpCode = form.get("code")
const totpRegistrationId = form.get("totpRegistrationId")
if (
!totpCode ||
typeof totpCode !== "string" ||
!totpRegistrationId ||
typeof totpRegistrationId !== "string"
) {
return {
error: true,
message: "Invalid values",
responsePayload: null,
}
}

const session = await getServerSession(authOptions)
const token = session?.accessToken

if (!token || typeof token !== "string") {
return {
error: true,
message: "Invalid Token",
responsePayload: null,
}
}

let totpValidationResponse: UserTotpRegistrationValidateMutation | null | undefined
try {
totpValidationResponse = await userTotpRegistrationValidate(
totpCode,
totpRegistrationId,
token,
)
} catch (err) {
console.log("error in userTotpRegistrationValidate ", err)
return {
error: true,
message:
"Something went wrong. Please try again. If the error persists, contact support.",
responsePayload: null,
}
}

if (totpValidationResponse?.userTotpRegistrationValidate.errors.length) {
return {
error: true,
message: totpValidationResponse?.userTotpRegistrationValidate.errors[0].message,
responsePayload: null,
}
}

redirect("/security")
}

export const deleteTotpServerAction = async () => {
const session = await getServerSession(authOptions)
const token = session?.accessToken
if (!token && typeof token !== "string") {
return
}
await userTotpDelete(token)
revalidatePath("/security")
}
Loading

0 comments on commit ffce1c5

Please sign in to comment.