damus.io

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

PurpleLogin.tsx (12758B)


      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 
     17 
     18 // TODO: Double-check this regex and make it more accurate
     19 const NPUB_REGEX = /^npub[0-9A-Za-z]+$/
     20 
     21 export function PurpleLogin() {
     22   const intl = useIntl()
     23   const [sessionToken, setSessionToken] = useLocalStorage('session_token', null)
     24   const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified
     25   const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay
     26   const [error, setError] = useState<string | null>(null)  // An error message to display to the user
     27   const [npubValidationError, setNpubValidationError] = useState<string | null>(null)
     28   const [npub, setNpub] = useState<string>("")
     29   const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined)  // The account info fetched from the server
     30   const [otpSent, setOTPSent] = useState<boolean>(false)
     31   const [otp, setOTP] = useState<string>("")
     32   const [otpVerified, setOTPVerified] = useState<boolean>(false)
     33   const [otpInvalid, setOTPInvalid] = useState<boolean>(false)
     34   const loginSuccessful = sessionToken !== null && otpVerified === true;
     35 
     36   // MARK: - Functions
     37 
     38   const fetchProfile = async () => {
     39     if (!pubkey) {
     40       return
     41     }
     42     try {
     43       const profile = await getProfile(pubkey)
     44       setProfile(profile)
     45     }
     46     catch (e) {
     47       console.error(e)
     48       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.")
     49     }
     50   }
     51 
     52   const fetchAccountInfo = async () => {
     53     if (!pubkey) {
     54       setExistingAccountInfo(undefined)
     55       return
     56     }
     57     try {
     58       const accountInfo = await getPurpleAccountInfo(pubkey)
     59       setExistingAccountInfo(accountInfo)
     60     }
     61     catch (e) {
     62       console.error(e)
     63       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.")
     64     }
     65   }
     66 
     67   const beginLogin = async () => {
     68     if (!pubkey || !existingAccountInfo) {
     69       return
     70     }
     71     const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/accounts/" + pubkey + "/request-otp", {
     72       method: 'POST',
     73       headers: {
     74         'Content-Type': 'application/json'
     75       },
     76     })
     77     if (!response.ok) {
     78       setError("Failed to send OTP. Please try again later.")
     79       return
     80     }
     81     setOTPSent(true)
     82     setOTPInvalid(false)
     83     setOTP("")
     84   }
     85 
     86   const completeOTP = async () => {
     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     setOTP("")
    145     if (npub.length > 0 && !NPUB_REGEX.test(npub)) {
    146       setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" }))
    147       setProfile(undefined)
    148     }
    149     else {
    150       setNpubValidationError(null)
    151       if (npub.length > 0) {
    152         try {
    153           const decoded = nip19.decode(npub)
    154           setPubkey(decoded.data as string)
    155         }
    156         catch (e) {
    157           setPubkey(null)
    158           setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" }))
    159         }
    160       }
    161       else {
    162         setProfile(undefined)
    163       }
    164     }
    165   }, [npub])
    166 
    167   useEffect(() => {
    168     if (otp.length != 6) {
    169       setOTPInvalid(false)
    170       setOTPVerified(false)
    171     }
    172   }, [otp])
    173 
    174 
    175   // MARK: - Render
    176 
    177   return (<>
    178     <ErrorDialog error={error} setError={setError} />
    179     <PurpleLayout>
    180       <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-2">
    181         {intl.formatMessage({ id: "purple.login.title", defaultMessage: "Access your account" })}
    182       </h2>
    183       <div className="flex flex-col text-center md:text-left mb-8">
    184         <span className="text-xs text-purple-200/50">
    185           {intl.formatMessage({ id: "purple.login.description", defaultMessage: "Use this page to access your Purple account details" })}
    186         </span>
    187       </div>
    188       <Label htmlFor="npub" className="text-purple-200/70 font-normal">
    189         {intl.formatMessage({ id: "purple.login.npub-label", defaultMessage: "Please enter your public key (npub) below" })}
    190       </Label>
    191       <Input id="npub" placeholder={intl.formatMessage({ id: "purple.login.npub-placeholder", defaultMessage: "npub…" })} type="text" className="mt-2" value={npub} onChange={(e) => setNpub(e.target.value)} required disabled={loginSuccessful} />
    192       {npubValidationError &&
    193         <Label htmlFor="npub" className="text-red-500 font-normal">
    194           {npubValidationError}
    195         </Label>
    196       }
    197       {((profile || profile === null) && pubkey) && (<>
    198         <div className="mt-2 mb-4 flex flex-col items-center">
    199           {existingAccountInfo !== null && existingAccountInfo !== undefined && otpSent !== true && (
    200             <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">
    201               <Sparkles className="w-4 h-4 shrink-0 text-purple-50" />
    202               <div className="flex flex-col">
    203                 <div className="text-purple-200/90 font-semibold text-md">
    204                   {intl.formatMessage({ id: "purple.login.this-account-exists", defaultMessage: "Yay! We found your account" })}
    205                 </div>
    206               </div>
    207             </div>
    208           )}
    209 
    210           <div className="text-purple-200/50 font-normal text-sm">
    211             {otpSent ? intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "Logging into:" })
    212               : intl.formatMessage({ id: "purple.login.is-this-you", defaultMessage: "Is this you?" })}
    213           </div>
    214           <div className="mt-4 flex flex-col gap-1 items-center justify-center mb-4">
    215             <Image src={profile?.picture || ("https://robohash.org/" + (profile?.pubkey || pubkey))} width={64} height={64} className="rounded-full" alt={profile?.name || intl.formatMessage({ id: "purple.login.unknown-user", defaultMessage: "Generic user avatar" })} />
    216             <div className="text-purple-100/90 font-semibold text-lg">
    217               {profile?.name || (npub.substring(0, 8) + ":" + npub.substring(npub.length - 8))}
    218             </div>
    219           </div>
    220           {existingAccountInfo === null && (
    221             <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">
    222               <Frown className="w-6 h-6 shrink-0 text-purple-50" />
    223               <div className="flex flex-col">
    224                 <div className="text-purple-200/90 font-semibold text-md">
    225                   {intl.formatMessage({ id: "purple.login.this-account-does-not-exist", defaultMessage: "This account does not exist" })}
    226                 </div>
    227                 <Link className="text-purple-200/90 font-normal text-sm underline" href="/purple/checkout" target="_blank">
    228                   {intl.formatMessage({ id: "purple.login.create-account", defaultMessage: "Join Purple today" })}
    229                 </Link>
    230               </div>
    231             </div>
    232           )}
    233           {existingAccountInfo !== null && !otpSent && (
    234             <Button variant="default" className="w-full" onClick={() => beginLogin()}>Continue</Button>
    235           )}
    236           {otpSent && (<>
    237             <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">
    238               <div className="flex flex-col items-center">
    239                 <Mail className="w-10 h-10 shrink-0 text-purple-100 mb-3" />
    240                 <div className="text-purple-200/90 font-semibold text-md whitespace-pre-line text-center">
    241                   {intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "We sent you a code via a Nostr DM.\n Please enter it below" })}
    242                 </div>
    243               </div>
    244             </div>
    245             <div className="mx-auto flex justify-center mb-4">
    246               <InputOTP6Digits value={otp} onChange={setOTP} onComplete={() => completeOTP()} disabled={loginSuccessful} />
    247             </div>
    248             {otpInvalid && (<div className="my-4 w-full flex flex-col gap-2">
    249               <div className="text-red-500 font-normal text-sm text-center">
    250                 {intl.formatMessage({ id: "purple.login.otp-invalid", defaultMessage: "Invalid or expired OTP. Please try again." })}
    251               </div>
    252               <Button variant="default" className="w-full" onClick={() => beginLogin()}>Resend OTP</Button>
    253             </div>)}
    254             <div className="text-purple-200/70 text-normal text-left font-semibold flex flex-col md:flex-row gap-3 rounded-lg bg-purple-200/10 p-3 items-center md:items-start">
    255               <Info className="w-6 h-6 shrink-0 mt-0 md:mt-1" />
    256               <div className="flex flex-col text-center md:text-left">
    257                 <span className="text-normal md:text-lg mb-2">
    258                   {intl.formatMessage({ id: "purple.login.stay-safe.title", defaultMessage: "Stay safe" })}
    259                 </span>
    260                 <span className="text-xs text-purple-200/50 whitespace-pre-line">
    261                   {intl.formatMessage({ id: "purple.login.stay-safe.message", defaultMessage: "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone.\n\n If you don't see the OTP code, please check the DM requests tab in Damus." })}
    262                 </span>
    263               </div>
    264             </div>
    265             {loginSuccessful && (<>
    266               <div className="flex flex-col justify-center items-center gap-2 mt-8">
    267                 <CheckCircle className="w-12 h-12 shrink-0 text-green-500" />
    268                 <div className="text-white text-sm text-center text-purple-200/80 mb-4">
    269                   {intl.formatMessage({ id: "purple.login.login-successful", defaultMessage: "Login successful. You should be automatically redirected. If not, please click the button below." })}
    270                 </div>
    271               </div>
    272               {/* Continue link */}
    273               <Link href={getRedirectURL() || "/purple/account"}>
    274                 <Button variant="default" className="w-full">
    275                   {intl.formatMessage({ id: "purple.login.continue", defaultMessage: "Continue" })}
    276                 </Button>
    277               </Link>
    278             </>)}
    279           </>)}
    280         </div>
    281       </>)}
    282     </PurpleLayout>
    283   </>)
    284 }