damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

PurpleLogin.tsx (9885B)


      1 import { CheckCircle, Frown, Mail, Sparkles } from "lucide-react";
      2 import { Button } from "../ui/Button";
      3 import { FormattedMessage, useIntl } from "react-intl";
      4 import Link from "next/link";
      5 import Image from "next/image";
      6 import { useEffect, useRef, useState } from "react";
      7 import { NostrEvent, Relay, nip19 } from "nostr-tools"
      8 import { Info } from "lucide-react";
      9 import { Input } from "@/components/ui/Input"
     10 import { Label } from "@/components/ui/Label"
     11 import { InputOTP6Digits } from "@/components/ui/InputOTP";
     12 import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils";
     13 import { useLocalStorage } from "usehooks-ts";
     14 import { ErrorDialog } from "../ErrorDialog";
     15 import { PurpleLayout } from "../PurpleLayout";
     16 import { NostrUserInput } from "../NostrUserInput";
     17 import { OTPAuth } from "../OTPAuth";
     18 
     19 
     20 // TODO: Double-check this regex and make it more accurate
     21 const NPUB_REGEX = /^npub[0-9A-Za-z]+$/
     22 
     23 export function PurpleLogin() {
     24   const intl = useIntl()
     25   const [sessionToken, setSessionToken] = useLocalStorage('session_token', null)
     26   const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified
     27   const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay
     28   const [error, setError] = useState<string | null>(null)  // An error message to display to the user
     29   const [npubValidationError, setNpubValidationError] = useState<string | null>(null)
     30   const [npub, setNpub] = useState<string>("")
     31   const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined)  // The account info fetched from the server
     32   const [otpSent, setOTPSent] = useState<boolean>(false)
     33   const [otpVerified, setOTPVerified] = useState<boolean>(false)
     34   const [otpInvalid, setOTPInvalid] = useState<boolean>(false)
     35   const loginSuccessful = sessionToken !== null && otpVerified === true;
     36 
     37   // MARK: - Functions
     38 
     39   const fetchProfile = async () => {
     40     if (!pubkey) {
     41       return
     42     }
     43     try {
     44       const profile = await getProfile(pubkey)
     45       setProfile(profile)
     46     }
     47     catch (e) {
     48       console.error(e)
     49       setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.")
     50     }
     51   }
     52 
     53   const fetchAccountInfo = async () => {
     54     if (!pubkey) {
     55       setExistingAccountInfo(undefined)
     56       return
     57     }
     58     try {
     59       const accountInfo = await getPurpleAccountInfo(pubkey)
     60       setExistingAccountInfo(accountInfo)
     61     }
     62     catch (e) {
     63       console.error(e)
     64       setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.")
     65     }
     66   }
     67 
     68   const beginLogin = async () => {
     69     if (!pubkey || !existingAccountInfo) {
     70       return
     71     }
     72     const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/accounts/" + pubkey + "/request-otp", {
     73       method: 'POST',
     74       headers: {
     75         'Content-Type': 'application/json'
     76       },
     77     })
     78     if (!response.ok) {
     79       setError("Failed to send OTP. Please try again later.")
     80       return
     81     }
     82     setOTPSent(true)
     83     setOTPInvalid(false)
     84   }
     85 
     86   const completeOTP = async (otp: string) => {
     87     if (!pubkey || !existingAccountInfo || !otp) {
     88       return
     89     }
     90     const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/accounts/" + pubkey + "/verify-otp", {
     91       method: 'POST',
     92       headers: {
     93         'Content-Type': 'application/json'
     94       },
     95       body: JSON.stringify({ otp_code: otp })
     96     })
     97     if (response.status != 200 && response.status != 401) {
     98       setError("Failed to verify OTP. Please try again later.")
     99       return
    100     }
    101     const json = await response.json()
    102     if (json.valid) {
    103       setSessionToken(json.session_token)
    104       setOTPVerified(true)
    105     }
    106     else {
    107       setOTPInvalid(true)
    108     }
    109   }
    110 
    111   const getRedirectURL = () => {
    112     const url = new URL(window.location.href)
    113     // Get the redirect URL from the query parameters, or fallback to the account page as the default redirect URL
    114     const redirect = url.searchParams.get("redirect") || "/purple/account"
    115     // Make sure the redirect URL is within the same domain for security reasons
    116     if (redirect && (redirect.startsWith(window.location.origin) || redirect.startsWith("/"))) {
    117       return redirect
    118     }
    119     return null
    120   }
    121 
    122   // MARK: - Effects and hooks
    123 
    124   // Load the profile when the pubkey changes
    125   useEffect(() => {
    126     if (pubkey) {
    127       fetchProfile()
    128       fetchAccountInfo()
    129     }
    130   }, [pubkey])
    131 
    132   useEffect(() => {
    133     if (sessionToken) {
    134       const redirectUrl = getRedirectURL()
    135       if (redirectUrl) {
    136         window.location.href = redirectUrl
    137       }
    138     }
    139   }, [sessionToken])
    140 
    141   useEffect(() => {
    142     setOTPSent(false)
    143     setOTPVerified(false)
    144     if (npub.length > 0 && !NPUB_REGEX.test(npub)) {
    145       setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" }))
    146       setProfile(undefined)
    147     }
    148     else {
    149       setNpubValidationError(null)
    150       if (npub.length > 0) {
    151         try {
    152           const decoded = nip19.decode(npub)
    153           setPubkey(decoded.data as string)
    154         }
    155         catch (e) {
    156           setPubkey(null)
    157           setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" }))
    158         }
    159       }
    160       else {
    161         setProfile(undefined)
    162       }
    163     }
    164   }, [npub])
    165 
    166   // MARK: - Render
    167 
    168   return (<>
    169     <ErrorDialog error={error} setError={setError} />
    170     <PurpleLayout>
    171       <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-2">
    172         {intl.formatMessage({ id: "purple.login.title", defaultMessage: "Access your account" })}
    173       </h2>
    174       <div className="flex flex-col text-center md:text-left mb-8">
    175         <span className="text-xs text-purple-200/50">
    176           {intl.formatMessage({ id: "purple.login.description", defaultMessage: "Use this page to access your Purple account details" })}
    177         </span>
    178       </div>
    179       <NostrUserInput
    180         pubkey={pubkey}
    181         setPubkey={setPubkey}
    182         onProfileChange={setProfile}
    183         disabled={loginSuccessful}
    184         profileHeader={<>
    185           {existingAccountInfo !== null && existingAccountInfo !== undefined && otpSent !== true && (
    186             <div className="text-purple-200/50 font-normal flex items-center gap-2 rounded-full px-6 py-2 justify-center mt-2 mb-2">
    187               <Sparkles className="w-4 h-4 shrink-0 text-purple-50" />
    188               <div className="flex flex-col">
    189                 <div className="text-purple-200/90 font-semibold text-md">
    190                   {intl.formatMessage({ id: "purple.login.this-account-exists", defaultMessage: "Yay! We found your account" })}
    191                 </div>
    192               </div>
    193             </div>
    194           )}
    195           <div className="text-purple-200/50 font-normal text-sm">
    196             {otpSent ? intl.formatMessage({ id: "purple.login.logging-into", defaultMessage: "Logging into:" })
    197               : intl.formatMessage({ id: "purple.login.is-this-you", defaultMessage: "Is this you?" })}
    198           </div>
    199         </>}
    200         profileFooter={<>
    201           {existingAccountInfo === null && (
    202             <div className="text-purple-200/50 font-normal flex items-center gap-2 bg-purple-300/10 rounded-full px-8 py-2 justify-center mt-2 mb-2 w-fit mx-auto">
    203               <Frown className="w-6 h-6 shrink-0 text-purple-50" />
    204               <div className="flex flex-col">
    205                 <div className="text-purple-200/90 font-semibold text-md">
    206                   {intl.formatMessage({ id: "purple.login.this-account-does-not-exist", defaultMessage: "This account does not exist" })}
    207                 </div>
    208                 <Link className="text-purple-200/90 font-normal text-sm underline" href="/purple/checkout" target="_blank">
    209                   {intl.formatMessage({ id: "purple.login.create-account", defaultMessage: "Join Purple today" })}
    210                 </Link>
    211               </div>
    212             </div>
    213           )}
    214         </>}
    215       />
    216       {((profile || profile === null) && pubkey) && (<>
    217         <div className="mt-2 mb-4 flex flex-col items-center">
    218           {existingAccountInfo !== null && !otpSent && (
    219             <Button variant="default" className="w-full" onClick={() => beginLogin()}>Continue</Button>
    220           )}
    221           {otpSent && (<>
    222             <OTPAuth
    223               pubkey={pubkey}
    224               verifyOTP={completeOTP}
    225               sendOTP={beginLogin}
    226               otpVerified={otpVerified}
    227               setOTPVerified={setOTPVerified}
    228               otpInvalid={otpInvalid}
    229               setOTPInvalid={setOTPInvalid}
    230               setError={setError}
    231               disabled={loginSuccessful}
    232             />
    233             {loginSuccessful && (<>
    234               <div className="flex flex-col justify-center items-center gap-2 mt-8">
    235                 <CheckCircle className="w-12 h-12 shrink-0 text-green-500" />
    236                 <div className="text-white text-sm text-center text-purple-200/80 mb-4">
    237                   {intl.formatMessage({ id: "purple.login.login-successful", defaultMessage: "Login successful. You should be automatically redirected. If not, please click the button below." })}
    238                 </div>
    239               </div>
    240               {/* Continue link */}
    241               <Link href={getRedirectURL() || "/purple/account"}>
    242                 <Button variant="default" className="w-full">
    243                   {intl.formatMessage({ id: "purple.login.continue", defaultMessage: "Continue" })}
    244                 </Button>
    245               </Link>
    246             </>)}
    247           </>)}
    248         </div>
    249       </>)}
    250     </PurpleLayout>
    251   </>)
    252 }